From fa4efb38f7abc70b9709cf9b315dbf956a0cec73 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Mon, 29 Jun 2026 18:26:17 -0700 Subject: [PATCH] feat(ksuid): add BinExpr, flexible parsers, and expression helpers Adds ParseStringKSUIDAny, ParseBinaryKSUIDAny, BinaryKSUID.BinExpr, and ExprStringKSUIDs for portal-compatible KSUID handling at API boundaries. Co-authored-by: Cursor --- CHANGELOG.md | 1 + README.md | 2 +- doc.go | 3 +- jet_expr.go | 9 ++++++ ksuid_binary.go | 9 ++++++ ksuid_parse.go | 69 ++++++++++++++++++++++++++++++++++++++++++++ ksuid_parse_test.go | 70 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 ksuid_parse.go create mode 100644 ksuid_parse_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 410c4e3..4435dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - `Query`, `MustQuery`, and `UpdateOne` helpers (+ Context variants). - `WithTxValue` for transactional functions that return a value. - `QueryCount`, `BuildQueryCountFn`, and `CountResult` for pagination counts. +- `BinaryKSUID.BinExpr`, `ExprStringKSUIDs`, and flexible `Parse*Any` KSUID parsers. - Context-aware variants for all query and mutation helpers. - `Delete`, `DeleteAffected`, `WithTx`, `ContainsCol`, and `CurrentDialect()`. - Package documentation (`doc.go`), expanded README, and subpackage docs for `dbxm` / `dbxp`. diff --git a/README.md b/README.md index 3242d35..584677d 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ For Postgres, use `dbx.DialectPostgres` and blank-import `dbxp` instead of `dbxm | Query | `Fetch`, `MustFetch`, `FetchOne`, `MustFetchOne`, `Query`, `MustQuery` (+ `*Context` variants) | | Mutations | `Insert`, `InsertReturning`, `Update`, `UpdateAffected`, `UpdateOne`, `UpdateReturning`, `Delete`, `DeleteAffected` (+ `*Context` variants) | | Transactions | `WithTx`, `WithTxValue` | -| Columns | `NormalCols`, `ContainsCol`, `ExprValues`, `ExprStringers`, `QueryCount`, `BuildQueryCountFn` | +| Columns | `NormalCols`, `ContainsCol`, `ExprValues`, `ExprStringers`, `ExprStringKSUIDs`, `QueryCount`, `BuildQueryCountFn` | | Partial update | `ApplyPtr`, `ApplyComplexPtr`, `ApplyInterfacePtr`, `ApplyVal` | | Pointers | `Ptr`, `Val`, `NowPtr`, `TrimPtr`, `TrimPtrToNil`, `IsZero` | | Types | `StringKSUID`, `BinaryKSUID`, `JSONB` | diff --git a/doc.go b/doc.go index 0270efa..b1785eb 100644 --- a/doc.go +++ b/doc.go @@ -28,7 +28,8 @@ // SQL encoding. Both implement [ApplyInterface] via Equal and IsZero for use // with [ApplyInterfacePtr]. Both share identical GraphQL string scalar transit. // Choose StringKSUID for text columns (VARCHAR, TEXT); choose BinaryKSUID for -// binary columns (BINARY(20), BYTEA). +// binary columns (BINARY(20), BYTEA). [ParseStringKSUIDAny] and +// [ParseBinaryKSUIDAny] accept flexible input for API boundaries. // // [JSONB] provides map-based JSON column scanning for Postgres JSONB and MySQL JSON. // diff --git a/jet_expr.go b/jet_expr.go index 81a5e07..8d85d1a 100644 --- a/jet_expr.go +++ b/jet_expr.go @@ -24,3 +24,12 @@ func ExprStringers(values []fmt.Stringer) []Expression { } return expressions } + +// ExprStringKSUIDs converts StringKSUID pointers to Jet string expressions. +func ExprStringKSUIDs(ids []*StringKSUID) []Expression { + expressions := make([]Expression, len(ids)) + for i, id := range ids { + expressions[i] = mysql.String(id.String()) + } + return expressions +} diff --git a/ksuid_binary.go b/ksuid_binary.go index d305428..31383eb 100644 --- a/ksuid_binary.go +++ b/ksuid_binary.go @@ -5,6 +5,7 @@ import ( "fmt" "io" + "github.com/go-jet/jet/v2/mysql" "github.com/segmentio/ksuid" ) @@ -43,6 +44,14 @@ func ParseBinaryKSUID(b []byte) (BinaryKSUID, error) { return BinaryKSUID{KSUID: id}, nil } +// BinExpr returns a Jet string expression for binary-encoded IN clauses. +func (b BinaryKSUID) BinExpr() mysql.StringExpression { + if b.IsNil() { + return mysql.StringExp(mysql.NULL) + } + return mysql.String(string(b.Bytes())) +} + // AsStringKSUID returns a StringKSUID view of the same identifier. func (b BinaryKSUID) AsStringKSUID() StringKSUID { return StringKSUID{KSUID: b.KSUID} diff --git a/ksuid_parse.go b/ksuid_parse.go new file mode 100644 index 0000000..47cfe93 --- /dev/null +++ b/ksuid_parse.go @@ -0,0 +1,69 @@ +package dbx + +import ( + "encoding/hex" + "fmt" + + "github.com/segmentio/ksuid" +) + +// ParseStringKSUIDAny parses a StringKSUID from a string, byte slice, ksuid.KSUID, +// or nil. Accepts base62 string (27 bytes), raw binary (20 bytes), or hex-encoded +// forms (40 or 54 bytes). +func ParseStringKSUIDAny(src any) (StringKSUID, error) { + id, err := parseKSUIDAny(src) + if err != nil { + return NilStringKSUID, err + } + return StringKSUID{KSUID: id}, nil +} + +// ParseBinaryKSUIDAny parses a BinaryKSUID from the same accepted forms as +// [ParseStringKSUIDAny]. +func ParseBinaryKSUIDAny(src any) (BinaryKSUID, error) { + id, err := parseKSUIDAny(src) + if err != nil { + return NilBinaryKSUID, err + } + return BinaryKSUID{KSUID: id}, nil +} + +func parseKSUIDAny(src any) (ksuid.KSUID, error) { + switch v := src.(type) { + case ksuid.KSUID: + return v, nil + case StringKSUID: + return v.KSUID, nil + case BinaryKSUID: + return v.KSUID, nil + case string: + return parseKSUIDBytes([]byte(v)) + case []byte: + return parseKSUIDBytes(v) + case nil: + return ksuid.Nil, nil + default: + return ksuid.Nil, fmt.Errorf("cannot parse KSUID from type %T", src) + } +} + +func parseKSUIDBytes(src []byte) (ksuid.KSUID, error) { + if len(src) == 40 || len(src) == 54 { + decoded := make([]byte, hex.DecodedLen(len(src))) + if _, err := hex.Decode(decoded, src); err != nil { + return ksuid.Nil, err + } + src = decoded + } + + switch len(src) { + case 0: + return ksuid.Nil, nil + case ksuidBinaryLength: + return ksuid.FromBytes(src) + case ksuidStringLength: + return ksuid.Parse(string(src)) + default: + return ksuid.Nil, fmt.Errorf("cannot parse KSUID from byte slice of length %d", len(src)) + } +} diff --git a/ksuid_parse_test.go b/ksuid_parse_test.go new file mode 100644 index 0000000..6428676 --- /dev/null +++ b/ksuid_parse_test.go @@ -0,0 +1,70 @@ +package dbx + +import ( + "encoding/hex" + "testing" + + "github.com/segmentio/ksuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseStringKSUIDAny(t *testing.T) { + id := ksuid.New() + stringForm := id.String() + byteForm := id.Bytes() + hexForm := hex.EncodeToString(byteForm) + + cases := []struct { + name string + src any + }{ + {"string", stringForm}, + {"bytes string form", []byte(stringForm)}, + {"bytes binary form", byteForm}, + {"hex", []byte(hexForm)}, + {"ksuid", id}, + {"StringKSUID", StringKSUID{KSUID: id}}, + {"nil", nil}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseStringKSUIDAny(tc.src) + require.NoError(t, err) + if tc.src == nil { + assert.True(t, got.IsZero()) + return + } + assert.Equal(t, id.String(), got.String()) + }) + } +} + +func TestParseBinaryKSUIDAny(t *testing.T) { + id := ksuid.New() + got, err := ParseBinaryKSUIDAny(id.Bytes()) + require.NoError(t, err) + assert.Equal(t, id.Bytes(), got.Bytes()) +} + +func TestParseStringKSUIDAny_InvalidType(t *testing.T) { + _, err := ParseStringKSUIDAny(123) + assert.Error(t, err) +} + +func TestBinaryKSUID_BinExpr(t *testing.T) { + id := NewBinaryKSUID() + expr := id.BinExpr() + require.NotNil(t, expr) + + nilExpr := NilBinaryKSUID.BinExpr() + require.NotNil(t, nilExpr) +} + +func TestExprStringKSUIDs(t *testing.T) { + a := NewStringKSUID() + b := NewStringKSUID() + exprs := ExprStringKSUIDs([]*StringKSUID{&a, &b}) + require.Len(t, exprs, 2) +}