feat: add StringKSUID and BinaryKSUID types
Replace the UUID wrapper with storage-specific KSUID types that share GraphQL string transit but use distinct SQL Scan/Value encodings. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,8 +21,8 @@
|
|||||||
//
|
//
|
||||||
// # Identifier types
|
// # Identifier types
|
||||||
//
|
//
|
||||||
// [UUID] wraps segmentio/ksuid for GraphQL APIs. [JSONB] provides map-based
|
// [StringKSUID] and [BinaryKSUID] wrap segmentio/ksuid with storage-specific
|
||||||
// JSON column scanning for Postgres JSONB and MySQL JSON.
|
// SQL encoding. [JSONB] provides map-based JSON column scanning.
|
||||||
//
|
//
|
||||||
// # Pointer and string utilities
|
// # Pointer and string utilities
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/segmentio/ksuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BinaryKSUID wraps segmentio/ksuid for binary-column storage (BINARY(20), BYTEA).
|
||||||
|
// SQL Scan/Value use the raw 20-byte encoding. GraphQL transit uses the shared
|
||||||
|
// string JSON transport (see ksuid_gql.go).
|
||||||
|
type BinaryKSUID struct {
|
||||||
|
ksuid.KSUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBinaryKSUID generates a new BinaryKSUID.
|
||||||
|
func NewBinaryKSUID() BinaryKSUID {
|
||||||
|
return BinaryKSUID{KSUID: ksuid.New()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBinaryKSUID parses a BinaryKSUID from its raw 20-byte form.
|
||||||
|
func ParseBinaryKSUID(b []byte) (BinaryKSUID, error) {
|
||||||
|
id, err := ksuid.FromBytes(b)
|
||||||
|
if err != nil {
|
||||||
|
return BinaryKSUID{}, err
|
||||||
|
}
|
||||||
|
return BinaryKSUID{KSUID: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsStringKSUID returns a StringKSUID view of the same identifier.
|
||||||
|
func (b BinaryKSUID) AsStringKSUID() StringKSUID {
|
||||||
|
return StringKSUID{KSUID: b.KSUID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan implements sql.Scanner for binary-backed KSUID columns.
|
||||||
|
func (b *BinaryKSUID) Scan(src any) error {
|
||||||
|
switch v := src.(type) {
|
||||||
|
case nil:
|
||||||
|
*b = BinaryKSUID{}
|
||||||
|
return nil
|
||||||
|
case []byte:
|
||||||
|
if len(v) == ksuidStringLength {
|
||||||
|
return fmt.Errorf("BinaryKSUID.Scan: string-encoded KSUID (%d bytes); use StringKSUID", len(v))
|
||||||
|
}
|
||||||
|
id, err := ksuid.FromBytes(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("BinaryKSUID.Scan: %w", err)
|
||||||
|
}
|
||||||
|
*b = BinaryKSUID{KSUID: id}
|
||||||
|
return nil
|
||||||
|
case string:
|
||||||
|
return fmt.Errorf("BinaryKSUID.Scan: string value %q; use StringKSUID or store raw bytes", v)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("BinaryKSUID.Scan: unable to scan type %T", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements driver.Valuer for binary-backed KSUID columns.
|
||||||
|
func (b BinaryKSUID) Value() (driver.Value, error) {
|
||||||
|
if b.IsNil() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return b.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalGQL implements the graphql.Unmarshaler interface.
|
||||||
|
func (b *BinaryKSUID) UnmarshalGQL(value any) error {
|
||||||
|
return unmarshalKSUIDFromGQL(value, b.UnmarshalText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalGQL implements the graphql.Marshaler interface.
|
||||||
|
func (b BinaryKSUID) MarshalGQL(w io.Writer) {
|
||||||
|
marshalKSUIDToGQL(w, b.KSUID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/segmentio/ksuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ksuidGQLTransport struct {
|
||||||
|
KSUIDStr string `json:"ksuid_str"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalKSUIDFromGQL(value any, unmarshal func([]byte) error) error {
|
||||||
|
str, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("GraphQL failed to unmarshal KSUID value: %v", value)
|
||||||
|
}
|
||||||
|
return unmarshal([]byte(str))
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalKSUIDToGQL(w io.Writer, id ksuid.KSUID) {
|
||||||
|
transport := ksuidGQLTransport{KSUIDStr: id.String()}
|
||||||
|
data, err := json.Marshal(transport)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("GraphQL failed to JSON-marshal KSUID value: %s", err))
|
||||||
|
}
|
||||||
|
if _, err := w.Write(data); err != nil {
|
||||||
|
panic(fmt.Errorf("GraphQL failed to write KSUID value: %s", string(data)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ksuidBinaryLength = 20
|
||||||
|
ksuidStringLength = 27
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/segmentio/ksuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StringKSUID wraps segmentio/ksuid for text-column storage (VARCHAR, TEXT).
|
||||||
|
// SQL Scan/Value use the base62 string encoding. GraphQL transit uses the
|
||||||
|
// shared string JSON transport (see ksuid_gql.go).
|
||||||
|
type StringKSUID struct {
|
||||||
|
ksuid.KSUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStringKSUID generates a new StringKSUID.
|
||||||
|
func NewStringKSUID() StringKSUID {
|
||||||
|
return StringKSUID{KSUID: ksuid.New()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseStringKSUID parses a StringKSUID from its base62 string form.
|
||||||
|
func ParseStringKSUID(s string) (StringKSUID, error) {
|
||||||
|
id, err := ksuid.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return StringKSUID{}, err
|
||||||
|
}
|
||||||
|
return StringKSUID{KSUID: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsBinaryKSUID returns a BinaryKSUID view of the same identifier.
|
||||||
|
func (s StringKSUID) AsBinaryKSUID() BinaryKSUID {
|
||||||
|
return BinaryKSUID{KSUID: s.KSUID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan implements sql.Scanner for string-backed KSUID columns.
|
||||||
|
func (s *StringKSUID) Scan(src any) error {
|
||||||
|
switch v := src.(type) {
|
||||||
|
case nil:
|
||||||
|
*s = StringKSUID{}
|
||||||
|
return nil
|
||||||
|
case string:
|
||||||
|
id, err := ksuid.Parse(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("StringKSUID.Scan: %w", err)
|
||||||
|
}
|
||||||
|
*s = StringKSUID{KSUID: id}
|
||||||
|
return nil
|
||||||
|
case []byte:
|
||||||
|
if len(v) == ksuidBinaryLength {
|
||||||
|
return fmt.Errorf("StringKSUID.Scan: binary KSUID payload (%d bytes); use BinaryKSUID", len(v))
|
||||||
|
}
|
||||||
|
id, err := ksuid.Parse(string(v))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("StringKSUID.Scan: %w", err)
|
||||||
|
}
|
||||||
|
*s = StringKSUID{KSUID: id}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("StringKSUID.Scan: unable to scan type %T", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements driver.Valuer for string-backed KSUID columns.
|
||||||
|
func (s StringKSUID) Value() (driver.Value, error) {
|
||||||
|
if s.IsNil() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return s.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalGQL implements the graphql.Unmarshaler interface.
|
||||||
|
func (s *StringKSUID) UnmarshalGQL(value any) error {
|
||||||
|
return unmarshalKSUIDFromGQL(value, s.UnmarshalText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalGQL implements the graphql.Marshaler interface.
|
||||||
|
func (s StringKSUID) MarshalGQL(w io.Writer) {
|
||||||
|
marshalKSUIDToGQL(w, s.KSUID)
|
||||||
|
}
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/segmentio/ksuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStringKSUID_ScanValue(t *testing.T) {
|
||||||
|
id := NewStringKSUID()
|
||||||
|
require.False(t, id.IsNil())
|
||||||
|
|
||||||
|
val, err := id.Value()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, id.String(), val)
|
||||||
|
|
||||||
|
var scanned StringKSUID
|
||||||
|
require.NoError(t, scanned.Scan(id.String()))
|
||||||
|
assert.Equal(t, id, scanned)
|
||||||
|
|
||||||
|
require.NoError(t, scanned.Scan([]byte(id.String())))
|
||||||
|
assert.Equal(t, id, scanned)
|
||||||
|
|
||||||
|
var nilKSUID StringKSUID
|
||||||
|
val, err = nilKSUID.Value()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, val)
|
||||||
|
|
||||||
|
require.NoError(t, scanned.Scan(nil))
|
||||||
|
assert.True(t, scanned.IsNil())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringKSUID_ScanRejectsBinary(t *testing.T) {
|
||||||
|
var s StringKSUID
|
||||||
|
err := s.Scan(idBytes(t))
|
||||||
|
assert.ErrorContains(t, err, "BinaryKSUID")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringKSUID_Conversion(t *testing.T) {
|
||||||
|
s := NewStringKSUID()
|
||||||
|
b := s.AsBinaryKSUID()
|
||||||
|
assert.Equal(t, s.String(), b.String())
|
||||||
|
assert.Equal(t, s.Bytes(), b.Bytes())
|
||||||
|
assert.Equal(t, s, b.AsStringKSUID())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringKSUID_GQL(t *testing.T) {
|
||||||
|
id := NewStringKSUID()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
id.MarshalGQL(&buf)
|
||||||
|
|
||||||
|
var transport ksuidGQLTransport
|
||||||
|
require.NoError(t, json.Unmarshal(buf.Bytes(), &transport))
|
||||||
|
assert.Equal(t, id.String(), transport.KSUIDStr)
|
||||||
|
|
||||||
|
var parsed StringKSUID
|
||||||
|
require.NoError(t, parsed.UnmarshalGQL(id.String()))
|
||||||
|
assert.Equal(t, id, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBinaryKSUID_ScanValue(t *testing.T) {
|
||||||
|
id := NewBinaryKSUID()
|
||||||
|
require.False(t, id.IsNil())
|
||||||
|
|
||||||
|
val, err := id.Value()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, id.Bytes(), val)
|
||||||
|
|
||||||
|
var scanned BinaryKSUID
|
||||||
|
require.NoError(t, scanned.Scan(id.Bytes()))
|
||||||
|
assert.Equal(t, id, scanned)
|
||||||
|
|
||||||
|
var nilKSUID BinaryKSUID
|
||||||
|
val, err = nilKSUID.Value()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, val)
|
||||||
|
|
||||||
|
require.NoError(t, scanned.Scan(nil))
|
||||||
|
assert.True(t, scanned.IsNil())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBinaryKSUID_ScanRejectsStringEncoding(t *testing.T) {
|
||||||
|
s := NewStringKSUID()
|
||||||
|
var b BinaryKSUID
|
||||||
|
err := b.Scan([]byte(s.String()))
|
||||||
|
assert.ErrorContains(t, err, "StringKSUID")
|
||||||
|
|
||||||
|
err = b.Scan(s.String())
|
||||||
|
assert.ErrorContains(t, err, "StringKSUID")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBinaryKSUID_Conversion(t *testing.T) {
|
||||||
|
b := NewBinaryKSUID()
|
||||||
|
s := b.AsStringKSUID()
|
||||||
|
assert.Equal(t, b.String(), s.String())
|
||||||
|
assert.Equal(t, b, s.AsBinaryKSUID())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBinaryKSUID_GQL(t *testing.T) {
|
||||||
|
id := NewBinaryKSUID()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
id.MarshalGQL(&buf)
|
||||||
|
|
||||||
|
var transport ksuidGQLTransport
|
||||||
|
require.NoError(t, json.Unmarshal(buf.Bytes(), &transport))
|
||||||
|
assert.Equal(t, id.String(), transport.KSUIDStr)
|
||||||
|
|
||||||
|
var parsed BinaryKSUID
|
||||||
|
require.NoError(t, parsed.UnmarshalGQL(id.String()))
|
||||||
|
assert.Equal(t, id, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStringKSUID(t *testing.T) {
|
||||||
|
id := ksuid.New()
|
||||||
|
parsed, err := ParseStringKSUID(id.String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, id.String(), parsed.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBinaryKSUID(t *testing.T) {
|
||||||
|
id := ksuid.New()
|
||||||
|
parsed, err := ParseBinaryKSUID(id.Bytes())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, id.Bytes(), parsed.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func idBytes(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
return ksuid.New().Bytes()
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package dbx
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
|
|
||||||
"github.com/segmentio/ksuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UUID is a wrapper around ksuid.KSUID that implements the
|
|
||||||
// graphql.Unmarshaler and graphql.Marshaler interfaces for use in GraphQL APIs.
|
|
||||||
// It also provides additional convenience functions such as ParseUUID and NewUUID.
|
|
||||||
type UUID struct {
|
|
||||||
ksuid.KSUID
|
|
||||||
}
|
|
||||||
|
|
||||||
type uuidTransport struct {
|
|
||||||
UUIDStr string `json:"uuid_str"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generates a new, wrapped KSUID. In the strange case that random bytes can't be read, it will panic.
|
|
||||||
func NewUUID() UUID {
|
|
||||||
return UUID{KSUID: ksuid.New()}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseUUID parses a UUID from a string. If the string is not a valid UUID, it will return an error.
|
|
||||||
func ParseUUID(s string) (UUID, error) {
|
|
||||||
ksuid, err := ksuid.Parse(s)
|
|
||||||
if err != nil {
|
|
||||||
return UUID{}, err
|
|
||||||
}
|
|
||||||
return UUID{KSUID: ksuid}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalGQL implements the graphql.Unmarshaler interface
|
|
||||||
func (u *UUID) UnmarshalGQL(value interface{}) error {
|
|
||||||
slog.Debug("uuid unmarshaling from gql", "val", value)
|
|
||||||
str, ok := value.(string)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("GraphQL failed to unmarshal UUID value: %v", value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := u.UnmarshalText([]byte(str)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalGQL implements the graphql.Marshaler interface
|
|
||||||
func (u UUID) MarshalGQL(w io.Writer) {
|
|
||||||
transport := uuidTransport{UUIDStr: u.String()}
|
|
||||||
json, err := json.Marshal(transport)
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Errorf("GraphQL failed to JSON-marshal UUID value: %s", err))
|
|
||||||
}
|
|
||||||
slog.Debug("uuid marshaling to gql", "uuid", u.String(), "json", string(json))
|
|
||||||
_, err = w.Write(json)
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Errorf("GraphQL failed to write UUID value: %s", string(json)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user