Compare commits

...

4 Commits

Author SHA1 Message Date
end 7e30272020 feat(jet): add ExprPtrs for pointer slice expressions
ExprPtrs dereferences []*T values, skips nil entries, and maps survivors
through a converter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 18:49:19 -07:00
end a712ccc38f refactor(ksuid): replace BinExpr with top-level expr helpers
Adds StringKSUIDExpr and BinaryKSUIDExpr for use with ExprValues, plus
Expr() methods on both types. Removes ExprStringKSUIDs and BinExpr.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 18:49:19 -07:00
end eafe84ef31 feat(graphql): add JS-safe uint64 marshal helpers
Adds MarshalUint64, UnmarshalUint64, and Uint64Marshaler for gqlgen-
compatible uint64 string scalars without a gqlgen dependency.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 18:49:19 -07:00
end 338294f957 feat(ksuid): add BinExpr, flexible parsers, and expression helpers
Adds ParseStringKSUIDAny, ParseBinaryKSUIDAny, BinaryKSUID.BinExpr, and
ExprStringKSUIDs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 18:49:19 -07:00
11 changed files with 341 additions and 4 deletions
+2
View File
@@ -16,6 +16,8 @@
- `Query`, `MustQuery`, and `UpdateOne` helpers (+ Context variants). - `Query`, `MustQuery`, and `UpdateOne` helpers (+ Context variants).
- `WithTxValue` for transactional functions that return a value. - `WithTxValue` for transactional functions that return a value.
- `QueryCount`, `BuildQueryCountFn`, and `CountResult` for pagination counts. - `QueryCount`, `BuildQueryCountFn`, and `CountResult` for pagination counts.
- `StringKSUIDExpr`, `BinaryKSUIDExpr`, `ExprPtrs`, and flexible `Parse*Any` KSUID parsers.
- `MarshalUint64` and `UnmarshalUint64` for JS-safe GraphQL uint64 scalars.
- Context-aware variants for all query and mutation helpers. - Context-aware variants for all query and mutation helpers.
- `Delete`, `DeleteAffected`, `WithTx`, `ContainsCol`, and `CurrentDialect()`. - `Delete`, `DeleteAffected`, `WithTx`, `ContainsCol`, and `CurrentDialect()`.
- Package documentation (`doc.go`), expanded README, and subpackage docs for `dbxm` / `dbxp`. - Package documentation (`doc.go`), expanded README, and subpackage docs for `dbxm` / `dbxp`.
+2 -1
View File
@@ -45,10 +45,11 @@ For Postgres, use `dbx.DialectPostgres` and blank-import `dbxp` instead of `dbxm
| Query | `Fetch`, `MustFetch`, `FetchOne`, `MustFetchOne`, `Query`, `MustQuery` (+ `*Context` variants) | | Query | `Fetch`, `MustFetch`, `FetchOne`, `MustFetchOne`, `Query`, `MustQuery` (+ `*Context` variants) |
| Mutations | `Insert`, `InsertReturning`, `Update`, `UpdateAffected`, `UpdateOne`, `UpdateReturning`, `Delete`, `DeleteAffected` (+ `*Context` variants) | | Mutations | `Insert`, `InsertReturning`, `Update`, `UpdateAffected`, `UpdateOne`, `UpdateReturning`, `Delete`, `DeleteAffected` (+ `*Context` variants) |
| Transactions | `WithTx`, `WithTxValue` | | Transactions | `WithTx`, `WithTxValue` |
| Columns | `NormalCols`, `ContainsCol`, `ExprValues`, `ExprStringers`, `QueryCount`, `BuildQueryCountFn` | | Columns | `NormalCols`, `ContainsCol`, `ExprValues`, `ExprPtrs`, `ExprStringers`, `StringKSUIDExpr`, `BinaryKSUIDExpr`, `QueryCount`, `BuildQueryCountFn` |
| Partial update | `ApplyPtr`, `ApplyComplexPtr`, `ApplyInterfacePtr`, `ApplyVal` | | Partial update | `ApplyPtr`, `ApplyComplexPtr`, `ApplyInterfacePtr`, `ApplyVal` |
| Pointers | `Ptr`, `Val`, `NowPtr`, `TrimPtr`, `TrimPtrToNil`, `IsZero` | | Pointers | `Ptr`, `Val`, `NowPtr`, `TrimPtr`, `TrimPtrToNil`, `IsZero` |
| Types | `StringKSUID`, `BinaryKSUID`, `JSONB` | | Types | `StringKSUID`, `BinaryKSUID`, `JSONB` |
| GraphQL | `MarshalUint64`, `UnmarshalUint64` |
## KSUID type selection ## KSUID type selection
+11 -3
View File
@@ -16,7 +16,8 @@
// # Jet column utilities // # Jet column utilities
// //
// Dialect-neutral type aliases ([Column], [ColumnList]) and helpers for column // Dialect-neutral type aliases ([Column], [ColumnList]) and helpers for column
// lists ([NormalCols], [ContainsCol]) and expression building ([ExprValues]). // lists ([NormalCols], [ContainsCol]) and expression building ([ExprValues],
// [ExprPtrs], [StringKSUIDExpr], [BinaryKSUIDExpr]).
// [QueryCount] and [BuildQueryCountFn] support pagination total counts. // [QueryCount] and [BuildQueryCountFn] support pagination total counts.
// //
// Partial-update helpers ([ApplyPtr], [ApplyVal]) track changed fields for // Partial-update helpers ([ApplyPtr], [ApplyVal]) track changed fields for
@@ -26,12 +27,19 @@
// //
// [StringKSUID] and [BinaryKSUID] wrap segmentio/ksuid with storage-specific // [StringKSUID] and [BinaryKSUID] wrap segmentio/ksuid with storage-specific
// SQL encoding. Both implement [ApplyInterface] via Equal and IsZero for use // SQL encoding. Both implement [ApplyInterface] via Equal and IsZero for use
// with [ApplyInterfacePtr]. Both share identical GraphQL string scalar transit. // with [ApplyInterfacePtr]. Expr returns a Jet expression via [StringKSUIDExpr]
// or [BinaryKSUIDExpr]. Both share identical GraphQL string scalar transit.
// Choose StringKSUID for text columns (VARCHAR, TEXT); choose BinaryKSUID for // 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. // [JSONB] provides map-based JSON column scanning for Postgres JSONB and MySQL JSON.
// //
// # GraphQL scalars
//
// [MarshalUint64] and [UnmarshalUint64] provide JS-safe uint64 serialization for
// gqlgen without importing gqlgen directly.
//
// # Pointer and string utilities // # Pointer and string utilities
// //
// [Ptr], [Val], [TrimPtr], [StringToFilter], and related helpers for nullable // [Ptr], [Val], [TrimPtr], [StringToFilter], and related helpers for nullable
+55
View File
@@ -0,0 +1,55 @@
package dbx
import (
"fmt"
"io"
"strconv"
)
// Uint64Marshaler wraps a uint64 for GraphQL JSON output as a string scalar.
type Uint64Marshaler uint64
// MarshalGQL writes the uint64 as a JSON string for gqlgen compatibility.
func (u Uint64Marshaler) MarshalGQL(w io.Writer) {
fmt.Fprintf(w, "\"%d\"", uint64(u))
}
// MarshalUint64 marshals a uint64 as a JSON string to avoid JavaScript
// precision loss for integers larger than 2^53-1.
func MarshalUint64(i uint64) Uint64Marshaler {
return Uint64Marshaler(i)
}
// UnmarshalUint64 unmarshals a uint64 from a JSON string or number.
func UnmarshalUint64(v any) (uint64, error) {
switch value := v.(type) {
case string:
if value == "" {
return 0, nil
}
n, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid uint64 string: %w", err)
}
return n, nil
case int:
if value < 0 {
return 0, fmt.Errorf("invalid negative uint64: %d", value)
}
return uint64(value), nil
case int64:
if value < 0 {
return 0, fmt.Errorf("invalid negative uint64: %d", value)
}
return uint64(value), nil
case float64:
if value < 0 || value != float64(uint64(value)) {
return 0, fmt.Errorf("invalid uint64 number: %v", value)
}
return uint64(value), nil
case uint64:
return value, nil
default:
return 0, fmt.Errorf("invalid uint64 type: %T", v)
}
}
+49
View File
@@ -0,0 +1,49 @@
package dbx
import (
"bytes"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMarshalUint64(t *testing.T) {
var buf bytes.Buffer
MarshalUint64(9007199254740993).MarshalGQL(&buf)
assert.Equal(t, `"9007199254740993"`, buf.String())
}
func TestUnmarshalUint64_String(t *testing.T) {
n, err := UnmarshalUint64("42")
require.NoError(t, err)
assert.Equal(t, uint64(42), n)
}
func TestUnmarshalUint64_Number(t *testing.T) {
n, err := UnmarshalUint64(float64(42))
require.NoError(t, err)
assert.Equal(t, uint64(42), n)
}
func TestUnmarshalUint64_RoundTripJSON(t *testing.T) {
const original uint64 = 9007199254740993
var buf bytes.Buffer
MarshalUint64(original).MarshalGQL(&buf)
var wire string
require.NoError(t, json.Unmarshal(buf.Bytes(), &wire))
got, err := UnmarshalUint64(wire)
require.NoError(t, err)
assert.Equal(t, original, got)
}
func TestUnmarshalUint64_Invalid(t *testing.T) {
_, err := UnmarshalUint64(true)
assert.Error(t, err)
_, err = UnmarshalUint64(float64(-1))
assert.Error(t, err)
}
+29
View File
@@ -16,6 +16,19 @@ func ExprValues[T any](values []T, f func(T) Expression) []Expression {
return expressions return expressions
} }
// ExprPtrs converts a list of pointers to Expression values, skipping nil
// entries. Function f receives the dereferenced value.
func ExprPtrs[T any](values []*T, f func(T) Expression) []Expression {
expressions := make([]Expression, 0, len(values))
for _, v := range values {
if v == nil {
continue
}
expressions = append(expressions, f(*v))
}
return expressions
}
// ExprStringers converts a list of fmt.Stringers to a list of Expression values. // ExprStringers converts a list of fmt.Stringers to a list of Expression values.
func ExprStringers(values []fmt.Stringer) []Expression { func ExprStringers(values []fmt.Stringer) []Expression {
expressions := make([]Expression, len(values)) expressions := make([]Expression, len(values))
@@ -24,3 +37,19 @@ func ExprStringers(values []fmt.Stringer) []Expression {
} }
return expressions return expressions
} }
// StringKSUIDExpr returns a Jet expression for a string-encoded KSUID column.
func StringKSUIDExpr(id StringKSUID) Expression {
if id.IsNil() {
return mysql.StringExp(mysql.NULL)
}
return mysql.String(id.String())
}
// BinaryKSUIDExpr returns a Jet expression for a binary-encoded KSUID column.
func BinaryKSUIDExpr(id BinaryKSUID) Expression {
if id.IsNil() {
return mysql.StringExp(mysql.NULL)
}
return mysql.String(string(id.Bytes()))
}
+60
View File
@@ -0,0 +1,60 @@
package dbx
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStringKSUIDExpr(t *testing.T) {
id := NewStringKSUID()
expr := StringKSUIDExpr(id)
require.NotNil(t, expr)
assert.Equal(t, expr, id.Expr())
nilExpr := StringKSUIDExpr(NilStringKSUID)
require.NotNil(t, nilExpr)
assert.Equal(t, nilExpr, NilStringKSUID.Expr())
}
func TestBinaryKSUIDExpr(t *testing.T) {
id := NewBinaryKSUID()
expr := BinaryKSUIDExpr(id)
require.NotNil(t, expr)
assert.Equal(t, expr, id.Expr())
nilExpr := BinaryKSUIDExpr(NilBinaryKSUID)
require.NotNil(t, nilExpr)
assert.Equal(t, nilExpr, NilBinaryKSUID.Expr())
}
func TestExprPtrs_StringKSUID(t *testing.T) {
a := NewStringKSUID()
b := NewStringKSUID()
exprs := ExprPtrs([]*StringKSUID{&a, nil, &b}, StringKSUIDExpr)
require.Len(t, exprs, 2)
}
func TestExprPtrs_BinaryKSUID(t *testing.T) {
a := NewBinaryKSUID()
exprs := ExprPtrs([]*BinaryKSUID{&a, nil}, BinaryKSUIDExpr)
require.Len(t, exprs, 1)
assert.NotNil(t, exprs[0])
}
func TestExprValues_StringKSUID(t *testing.T) {
a := NewStringKSUID()
b := NewStringKSUID()
exprs := ExprValues([]*StringKSUID{&a, &b}, func(id *StringKSUID) Expression {
return id.Expr()
})
require.Len(t, exprs, 2)
}
func TestExprValues_BinaryKSUID(t *testing.T) {
a := NewBinaryKSUID()
exprs := ExprValues([]BinaryKSUID{a}, BinaryKSUIDExpr)
require.Len(t, exprs, 1)
assert.NotNil(t, exprs[0])
}
+5
View File
@@ -48,6 +48,11 @@ func (b BinaryKSUID) AsStringKSUID() StringKSUID {
return StringKSUID{KSUID: b.KSUID} return StringKSUID{KSUID: b.KSUID}
} }
// Expr returns a Jet expression for this BinaryKSUID. See [BinaryKSUIDExpr].
func (b BinaryKSUID) Expr() Expression {
return BinaryKSUIDExpr(b)
}
// Scan implements sql.Scanner for binary-backed KSUID columns. // Scan implements sql.Scanner for binary-backed KSUID columns.
func (b *BinaryKSUID) Scan(src any) error { func (b *BinaryKSUID) Scan(src any) error {
switch v := src.(type) { switch v := src.(type) {
+69
View File
@@ -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))
}
}
+54
View File
@@ -0,0 +1,54 @@
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)
}
+5
View File
@@ -48,6 +48,11 @@ func (s StringKSUID) AsBinaryKSUID() BinaryKSUID {
return BinaryKSUID{KSUID: s.KSUID} return BinaryKSUID{KSUID: s.KSUID}
} }
// Expr returns a Jet expression for this StringKSUID. See [StringKSUIDExpr].
func (s StringKSUID) Expr() Expression {
return StringKSUIDExpr(s)
}
// Scan implements sql.Scanner for string-backed KSUID columns. // Scan implements sql.Scanner for string-backed KSUID columns.
func (s *StringKSUID) Scan(src any) error { func (s *StringKSUID) Scan(src any) error {
switch v := src.(type) { switch v := src.(type) {