feat: Generate V6 from custom time (#172) * Add NewV6WithTime * Refactor generateV6 * fix NewV6WithTime doc comment * fix: remove fmt.Println from test --------- Co-authored-by: nicumicle <20170987+nicumicleI@users.noreply.github.com>
diff --git a/time.go b/time.go index aa1df76..efa4d42 100644 --- a/time.go +++ b/time.go
@@ -45,11 +45,16 @@ func GetTime() (Time, uint16, error) { defer timeMu.Unlock() timeMu.Lock() - return getTime() + return getTime(nil) } -func getTime() (Time, uint16, error) { - t := timeNow() +func getTime(customTime *time.Time) (Time, uint16, error) { + var t time.Time + if customTime == nil { // When not provided, use the current time + t = timeNow() + } else { + t = *customTime + } // If we don't have a clock sequence already, set one. if clockSeq == 0 {
diff --git a/time_test.go b/time_test.go new file mode 100644 index 0000000..46354a4 --- /dev/null +++ b/time_test.go
@@ -0,0 +1,44 @@ +package uuid + +import ( + "testing" + "time" +) + +func TestGetTime(t *testing.T) { + now := time.Now() + tt := map[string]struct { + input func() *time.Time + expectedTime int64 + }{ + "it should return the current time": { + input: func() *time.Time { + return nil + }, + expectedTime: now.Unix(), + }, + "it should return the provided time": { + input: func() *time.Time { + parsed, err := time.Parse(time.RFC3339, "2024-10-15T09:32:23Z") + if err != nil { + t.Errorf("timeParse unexpected error: %v", err) + } + return &parsed + }, + expectedTime: 1728984743, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + result, _, err := getTime(tc.input()) + if err != nil { + t.Errorf("getTime unexpected error: %v", err) + } + sec, _ := result.UnixTime() + if sec != tc.expectedTime { + t.Errorf("expected %v, got %v", tc.expectedTime, result) + } + }) + } +}
diff --git a/version6.go b/version6.go index 3b6d011..17bbafe 100644 --- a/version6.go +++ b/version6.go
@@ -4,7 +4,10 @@ package uuid -import "encoding/binary" +import ( + "encoding/binary" + "time" +) // UUID version 6 is a field-compatible version of UUIDv1, reordered for improved DB locality. // It is expected that UUIDv6 will primarily be used in contexts where there are existing v1 UUIDs. @@ -19,11 +22,31 @@ // SetClockSequence then it will be set automatically. If GetTime fails to // return the current NewV6 returns Nil and an error. func NewV6() (UUID, error) { - var uuid UUID now, seq, err := GetTime() if err != nil { - return uuid, err + return Nil, err } + return generateV6(now, seq), nil +} + +// NewV6WithTime returns a Version 6 UUID based on the current NodeID, clock +// sequence, and a specified time. It is similar to the NewV6 function, but allows +// you to specify the time. If time is passed as nil, then the current time is used. +// +// There is a limit on how many UUIDs can be generated for the same time, so if you +// are generating multiple UUIDs, it is recommended to increment the time. +// If getTime fails to return the current NewV6WithTime returns Nil and an error. +func NewV6WithTime(customTime *time.Time) (UUID, error) { + now, seq, err := getTime(customTime) + if err != nil { + return Nil, err + } + + return generateV6(now, seq), nil +} + +func generateV6(now Time, seq uint16) UUID { + var uuid UUID /* 0 1 2 3 @@ -56,5 +79,5 @@ copy(uuid[10:], nodeID[:]) nodeMu.Unlock() - return uuid, nil + return uuid }
diff --git a/version6_test.go b/version6_test.go new file mode 100644 index 0000000..690c09d --- /dev/null +++ b/version6_test.go
@@ -0,0 +1,91 @@ +package uuid + +import ( + "testing" + "time" +) + +func TestNewV6WithTime(t *testing.T) { + testCases := map[string]string{ + "test with current date": time.Now().Format(time.RFC3339), // now + "test with past date": time.Now().Add(-1 * time.Hour * 24 * 365).Format(time.RFC3339), // 1 year ago + "test with future date": time.Now().Add(time.Hour * 24 * 365).Format(time.RFC3339), // 1 year from now + "test with different timezone": "2021-09-01T12:00:00+04:00", + "test with negative timezone": "2021-09-01T12:00:00-12:00", + "test with future date in different timezone": "2124-09-23T12:43:30+09:00", + } + + for testName, inputTime := range testCases { + t.Run(testName, func(t *testing.T) { + customTime, err := time.Parse(time.RFC3339, inputTime) + if err != nil { + t.Errorf("time.Parse returned unexpected error %v", err) + } + id, err := NewV6WithTime(&customTime) + if err != nil { + t.Errorf("NewV6WithTime returned unexpected error %v", err) + } + + if id.Version() != 6 { + t.Errorf("got %d, want version 6", id.Version()) + } + unixTime := time.Unix(id.Time().UnixTime()) + // Compare the times in UTC format, since the input time might have different timezone, + // and the result is always in system timezone + if customTime.UTC().Format(time.RFC3339) != unixTime.UTC().Format(time.RFC3339) { + t.Errorf("got %s, want %s", unixTime.Format(time.RFC3339), customTime.Format(time.RFC3339)) + } + }) + } +} + +func TestNewV6FromTimeGeneratesUniqueUUIDs(t *testing.T) { + now := time.Now() + ids := make([]string, 0) + runs := 26000 + + for i := 0; i < runs; i++ { + now = now.Add(time.Nanosecond) // Without this line, we can generate only 16384 UUIDs for the same timestamp + id, err := NewV6WithTime(&now) + if err != nil { + t.Errorf("NewV6WithTime returned unexpected error %v", err) + } + if id.Version() != 6 { + t.Errorf("got %d, want version 6", id.Version()) + } + + // Make sure we add only unique values + if !contains(t, ids, id.String()) { + ids = append(ids, id.String()) + } + } + + // Check we added all the UIDs + if len(ids) != runs { + t.Errorf("got %d UUIDs, want %d", len(ids), runs) + } +} + +func BenchmarkNewV6WithTime(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + now := time.Now() + _, err := NewV6WithTime(&now) + if err != nil { + b.Fatal(err) + } + } + }) +} + +func contains(t *testing.T, arr []string, str string) bool { + t.Helper() + + for _, a := range arr { + if a == str { + return true + } + } + + return false +}