diff --git a/dbtype/int64id.go b/dbtype/int64id.go new file mode 100644 index 0000000..0c5f282 --- /dev/null +++ b/dbtype/int64id.go @@ -0,0 +1,25 @@ +package dbtype + +import "time" + +// Int64IDBits is the number of random bits in a typeid Int64. +// Bit layout: [48-bit unix ms timestamp][15-bit crypto/rand] = 63 bits, always positive. +const Int64IDBits = 15 + +// Int64IDTime extracts the millisecond-precision creation timestamp +// from an Int64 ID that uses the typeid bit layout. +func Int64IDTime(id int64) time.Time { + return time.UnixMilli(id >> Int64IDBits) +} + +// FloorInt64ID returns the lowest possible Int64 ID for timestamp t. +// All 15 random bits are zero. +func FloorInt64ID(t time.Time) int64 { + return t.UnixMilli() << Int64IDBits +} + +// CeilInt64ID returns the highest possible Int64 ID for timestamp t. +// All 15 random bits are one. +func CeilInt64ID(t time.Time) int64 { + return t.UnixMilli()<= ?", "column <= ?", +// or "1=1" depending on which bounds are set. +func (r TimeRange) ToSql() (string, []any, error) { + switch { + case r.floor != nil && r.ceil != nil: + return fmt.Sprintf("%s BETWEEN ? AND ?", r.column), []any{r.floor, r.ceil}, nil + case r.floor != nil: + return fmt.Sprintf("%s >= ?", r.column), []any{r.floor}, nil + case r.ceil != nil: + return fmt.Sprintf("%s <= ?", r.column), []any{r.ceil}, nil + default: + return "1=1", nil, nil + } +} diff --git a/dbtype/timerange_test.go b/dbtype/timerange_test.go new file mode 100644 index 0000000..5d11bdf --- /dev/null +++ b/dbtype/timerange_test.go @@ -0,0 +1,95 @@ +package dbtype_test + +import ( + "testing" + "time" + + "github.com/goware/pgkit/v2/dbtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTimeRange_UUIDv7_BothBounds(t *testing.T) { + since := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + until := time.Date(2026, 3, 27, 0, 0, 0, 0, time.UTC) + r := dbtype.UUIDv7Range("id", &since, &until) + + sql, args, err := r.ToSql() + require.NoError(t, err) + assert.Equal(t, "id BETWEEN ? AND ?", sql) + assert.Len(t, args, 2) +} + +func TestTimeRange_UUIDv7_SinceOnly(t *testing.T) { + since := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + r := dbtype.UUIDv7Range("id", &since, nil) + + sql, args, err := r.ToSql() + require.NoError(t, err) + assert.Equal(t, "id >= ?", sql) + assert.Len(t, args, 1) +} + +func TestTimeRange_UUIDv7_UntilOnly(t *testing.T) { + until := time.Date(2026, 3, 27, 0, 0, 0, 0, time.UTC) + r := dbtype.UUIDv7Range("id", nil, &until) + + sql, args, err := r.ToSql() + require.NoError(t, err) + assert.Equal(t, "id <= ?", sql) + assert.Len(t, args, 1) +} + +func TestTimeRange_UUIDv7_NeitherBound(t *testing.T) { + r := dbtype.UUIDv7Range("id", nil, nil) + + sql, args, err := r.ToSql() + require.NoError(t, err) + assert.Equal(t, "1=1", sql) + assert.Empty(t, args) +} + +func TestTimeRange_Int64ID_BothBounds(t *testing.T) { + since := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + until := time.Date(2026, 3, 27, 0, 0, 0, 0, time.UTC) + r := dbtype.Int64IDRange("id", &since, &until) + + sql, args, err := r.ToSql() + require.NoError(t, err) + assert.Equal(t, "id BETWEEN ? AND ?", sql) + assert.Len(t, args, 2) +} + +func TestTimeRange_FloorCeil_Accessors(t *testing.T) { + since := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + r := dbtype.UUIDv7Range("id", &since, nil) + + _, ok := r.Floor() + assert.True(t, ok) + _, ok = r.Ceil() + assert.False(t, ok) +} + +func TestTimeRange_UUIDv7_BoundValues(t *testing.T) { + since := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + until := time.Date(2026, 3, 27, 0, 0, 0, 0, time.UTC) + r := dbtype.UUIDv7Range("id", &since, &until) + + _, args, err := r.ToSql() + require.NoError(t, err) + + assert.Equal(t, dbtype.FloorUUIDv7(since), args[0]) + assert.Equal(t, dbtype.CeilUUIDv7(until), args[1]) +} + +func TestTimeRange_Int64ID_BoundValues(t *testing.T) { + since := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + until := time.Date(2026, 3, 27, 0, 0, 0, 0, time.UTC) + r := dbtype.Int64IDRange("id", &since, &until) + + _, args, err := r.ToSql() + require.NoError(t, err) + + assert.Equal(t, dbtype.FloorInt64ID(since), args[0]) + assert.Equal(t, dbtype.CeilInt64ID(until), args[1]) +} diff --git a/dbtype/uuidv7.go b/dbtype/uuidv7.go new file mode 100644 index 0000000..347490c --- /dev/null +++ b/dbtype/uuidv7.go @@ -0,0 +1,64 @@ +package dbtype + +import ( + "encoding/hex" + "time" +) + +// UUIDv7Time extracts the millisecond-precision creation timestamp +// embedded in a UUIDv7 (bytes 0-5, big-endian uint48 ms since epoch). +func UUIDv7Time(u [16]byte) time.Time { + ms := int64(u[0])<<40 | int64(u[1])<<32 | int64(u[2])<<24 | + int64(u[3])<<16 | int64(u[4])<<8 | int64(u[5]) + return time.UnixMilli(ms) +} + +// FormatUUID formats a [16]byte as a standard UUID string +// (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). +func FormatUUID(u [16]byte) string { + var buf [36]byte + hex.Encode(buf[0:8], u[0:4]) + buf[8] = '-' + hex.Encode(buf[9:13], u[4:6]) + buf[13] = '-' + hex.Encode(buf[14:18], u[6:8]) + buf[18] = '-' + hex.Encode(buf[19:23], u[8:10]) + buf[23] = '-' + hex.Encode(buf[24:36], u[10:16]) + return string(buf[:]) +} + +func setUUIDv7Timestamp(u *[16]byte, t time.Time) { + ms := uint64(t.UnixMilli()) + u[0] = byte(ms >> 40) + u[1] = byte(ms >> 32) + u[2] = byte(ms >> 24) + u[3] = byte(ms >> 16) + u[4] = byte(ms >> 8) + u[5] = byte(ms) +} + +// FloorUUIDv7 returns the lowest valid UUIDv7 for timestamp t. +// Version and variant bits are set; all random bits are zero. +func FloorUUIDv7(t time.Time) [16]byte { + var u [16]byte + setUUIDv7Timestamp(&u, t) + u[6] = 0x70 // version 7, rand_a = 0 + u[8] = 0x80 // variant 10xxxxxx, rand_b = 0 + return u +} + +// CeilUUIDv7 returns the highest valid UUIDv7 for timestamp t. +// Version and variant bits are set; all random bits are one. +func CeilUUIDv7(t time.Time) [16]byte { + var u [16]byte + setUUIDv7Timestamp(&u, t) + u[6] = 0x7f // version 7, rand_a high nibble all 1s + u[7] = 0xff // rand_a low byte all 1s + u[8] = 0xbf // variant 10, 6 bits all 1s + for i := 9; i < 16; i++ { + u[i] = 0xff + } + return u +} diff --git a/dbtype/uuidv7_test.go b/dbtype/uuidv7_test.go new file mode 100644 index 0000000..86e1f73 --- /dev/null +++ b/dbtype/uuidv7_test.go @@ -0,0 +1,80 @@ +package dbtype_test + +import ( + "crypto/rand" + "testing" + "time" + + "github.com/goware/pgkit/v2/dbtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestUUIDv7 generates a valid UUIDv7 for testing without external deps. +func newTestUUIDv7() [16]byte { + var u [16]byte + ms := uint64(time.Now().UnixMilli()) + u[0] = byte(ms >> 40) + u[1] = byte(ms >> 32) + u[2] = byte(ms >> 24) + u[3] = byte(ms >> 16) + u[4] = byte(ms >> 8) + u[5] = byte(ms) + _, _ = rand.Read(u[6:]) + u[6] = (u[6] & 0x0f) | 0x70 // version 7 + u[8] = (u[8] & 0x3f) | 0x80 // variant RFC4122 + return u +} + +func TestFloorUUIDv7_VersionAndVariant(t *testing.T) { + u := dbtype.FloorUUIDv7(time.Now()) + assert.Equal(t, byte(0x70), u[6]&0xf0, "version nibble") + assert.Equal(t, byte(0x80), u[8]&0xc0, "variant bits") +} + +func TestCeilUUIDv7_VersionAndVariant(t *testing.T) { + u := dbtype.CeilUUIDv7(time.Now()) + assert.Equal(t, byte(0x70), u[6]&0xf0, "version nibble") + assert.Equal(t, byte(0x80), u[8]&0xc0, "variant bits") +} + +func TestFloorUUIDv7_LessThanNewV7(t *testing.T) { + now := time.Now() + floor := dbtype.FormatUUID(dbtype.FloorUUIDv7(now)) + for range 100 { + v7 := newTestUUIDv7() + assert.LessOrEqual(t, floor, dbtype.FormatUUID(v7)) + } +} + +func TestCeilUUIDv7_GreaterThanFloor(t *testing.T) { + now := time.Now() + floor := dbtype.FormatUUID(dbtype.FloorUUIDv7(now)) + ceil := dbtype.FormatUUID(dbtype.CeilUUIDv7(now)) + assert.Greater(t, ceil, floor) +} + +func TestFloorCeilUUIDv7_BracketRealID(t *testing.T) { + v7 := newTestUUIDv7() + ts := dbtype.UUIDv7Time(v7) + + floor := dbtype.FormatUUID(dbtype.FloorUUIDv7(ts)) + ceil := dbtype.FormatUUID(dbtype.CeilUUIDv7(ts)) + id := dbtype.FormatUUID(v7) + assert.LessOrEqual(t, floor, id) + assert.GreaterOrEqual(t, ceil, id) +} + +func TestFloorUUIDv7_TimestampRoundTrip(t *testing.T) { + ts := time.Date(2026, 3, 27, 12, 0, 0, 0, time.UTC) + floor := dbtype.FloorUUIDv7(ts) + got := dbtype.UUIDv7Time(floor) + assert.Equal(t, ts.UnixMilli(), got.UnixMilli()) +} + +func TestUUIDv7Time_ExtractsTimestamp(t *testing.T) { + v7 := newTestUUIDv7() + got := dbtype.UUIDv7Time(v7) + require.False(t, got.IsZero()) + assert.WithinDuration(t, time.Now(), got, time.Second) +}