Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions dbtype/int64id.go
Original file line number Diff line number Diff line change
@@ -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()<<Int64IDBits | 0x7FFF
}
60 changes: 60 additions & 0 deletions dbtype/int64id_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package dbtype_test

import (
"crypto/rand"
"encoding/binary"
"testing"
"time"

"github.com/goware/pgkit/v2/dbtype"
"github.com/stretchr/testify/assert"
)

// newTestInt64ID generates an Int64 ID matching typeid's bit layout for testing.
func newTestInt64ID() int64 {
ms := time.Now().UnixMilli()
var rb [2]byte
_, _ = rand.Read(rb[:])
r := int64(binary.BigEndian.Uint16(rb[:]) & 0x7FFF)
return ms<<dbtype.Int64IDBits | r
}

func TestFloorInt64ID_LessThanNew(t *testing.T) {
now := time.Now()
floor := dbtype.FloorInt64ID(now)
for range 100 {
id := newTestInt64ID()
assert.LessOrEqual(t, floor, id)
}
}

func TestCeilInt64ID_GreaterThanFloor(t *testing.T) {
now := time.Now()
floor := dbtype.FloorInt64ID(now)
ceil := dbtype.CeilInt64ID(now)
assert.Greater(t, ceil, floor)
}

func TestFloorCeilInt64ID_BracketRealID(t *testing.T) {
id := newTestInt64ID()
ts := dbtype.Int64IDTime(id)

floor := dbtype.FloorInt64ID(ts)
ceil := dbtype.CeilInt64ID(ts)
assert.LessOrEqual(t, floor, id)
assert.GreaterOrEqual(t, ceil, id)
}

func TestFloorInt64ID_TimestampRoundTrip(t *testing.T) {
ts := time.Date(2026, 3, 27, 12, 0, 0, 0, time.UTC)
floor := dbtype.FloorInt64ID(ts)
got := dbtype.Int64IDTime(floor)
assert.Equal(t, ts.UnixMilli(), got.UnixMilli())
}

func TestInt64IDTime_ExtractsTimestamp(t *testing.T) {
id := newTestInt64ID()
got := dbtype.Int64IDTime(id)
assert.False(t, got.IsZero())
assert.WithinDuration(t, time.Now(), got, time.Second)
}
65 changes: 65 additions & 0 deletions dbtype/timerange.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package dbtype

import (
"fmt"
"time"
)

// TimeRange holds optional floor/ceil bounds for time-based ID range queries
// against a primary key column. It implements squirrel.Sqlizer, so it can be
// passed directly to squirrel.Where().
//
// Construct via [UUIDv7Range] or [Int64IDRange].
type TimeRange struct {
column string
floor any
ceil any
}

// UUIDv7Range builds a TimeRange that brackets column with [FloorUUIDv7] / [CeilUUIDv7].
// Nil since or until leaves that side unbounded.
func UUIDv7Range(column string, since, until *time.Time) TimeRange {
r := TimeRange{column: column}
if since != nil {
r.floor = FloorUUIDv7(*since)
}
if until != nil {
r.ceil = CeilUUIDv7(*until)
}
return r
}

// Int64IDRange builds a TimeRange that brackets column with [FloorInt64ID] / [CeilInt64ID].
// Nil since or until leaves that side unbounded.
func Int64IDRange(column string, since, until *time.Time) TimeRange {
r := TimeRange{column: column}
if since != nil {
r.floor = FloorInt64ID(*since)
}
if until != nil {
r.ceil = CeilInt64ID(*until)
}
return r
}

// Floor returns the lower-bound value and true, or (nil, false) if unbounded.
func (r TimeRange) Floor() (any, bool) { return r.floor, r.floor != nil }

// Ceil returns the upper-bound value and true, or (nil, false) if unbounded.
func (r TimeRange) Ceil() (any, bool) { return r.ceil, r.ceil != nil }

// ToSql implements squirrel.Sqlizer.
// Returns "column BETWEEN ? AND ?", "column >= ?", "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
}
}
95 changes: 95 additions & 0 deletions dbtype/timerange_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
64 changes: 64 additions & 0 deletions dbtype/uuidv7.go
Original file line number Diff line number Diff line change
@@ -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
}
80 changes: 80 additions & 0 deletions dbtype/uuidv7_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading