From eafe84ef31b0bed5875bbe6d046f344b15fb31de Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Mon, 29 Jun 2026 18:26:43 -0700 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + README.md | 1 + doc.go | 5 +++++ gql_uint64.go | 55 ++++++++++++++++++++++++++++++++++++++++++++++ gql_uint64_test.go | 49 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+) create mode 100644 gql_uint64.go create mode 100644 gql_uint64_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4435dad..a0690e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - `WithTxValue` for transactional functions that return a value. - `QueryCount`, `BuildQueryCountFn`, and `CountResult` for pagination counts. - `BinaryKSUID.BinExpr`, `ExprStringKSUIDs`, and flexible `Parse*Any` KSUID parsers. +- `MarshalUint64` and `UnmarshalUint64` for JS-safe GraphQL uint64 scalars. - 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 584677d..bfe555a 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ For Postgres, use `dbx.DialectPostgres` and blank-import `dbxp` instead of `dbxm | Partial update | `ApplyPtr`, `ApplyComplexPtr`, `ApplyInterfacePtr`, `ApplyVal` | | Pointers | `Ptr`, `Val`, `NowPtr`, `TrimPtr`, `TrimPtrToNil`, `IsZero` | | Types | `StringKSUID`, `BinaryKSUID`, `JSONB` | +| GraphQL | `MarshalUint64`, `UnmarshalUint64` | ## KSUID type selection diff --git a/doc.go b/doc.go index b1785eb..26e43de 100644 --- a/doc.go +++ b/doc.go @@ -33,6 +33,11 @@ // // [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 // // [Ptr], [Val], [TrimPtr], [StringToFilter], and related helpers for nullable diff --git a/gql_uint64.go b/gql_uint64.go new file mode 100644 index 0000000..a3ab5a5 --- /dev/null +++ b/gql_uint64.go @@ -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) + } +} diff --git a/gql_uint64_test.go b/gql_uint64_test.go new file mode 100644 index 0000000..4ef76ab --- /dev/null +++ b/gql_uint64_test.go @@ -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) +}