commit 66abc7efd647077de4e34ae7f601c095eec4da5c Author: Hein Date: Tue Jul 7 15:28:01 2026 +0200 refactor: :recycle: change resolvspec types to build in sqltypes diff --git a/README.md b/README.md new file mode 100644 index 0000000..2818d15 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# sqltypes + +Nullable SQL types for hand-written or generated Go models. Each type wraps a +value with a `Valid` flag and implements `database/sql.Scanner`, +`driver.Valuer`, `encoding/json`, `gopkg.in/yaml.v3`, and `encoding/xml` +marshalling — so a single struct field can be scanned from a database row, +round-tripped through JSON/YAML/XML, and written back to the database without +any per-format glue code. + +This package is what the `bun` and `gorm` writers emit when generating models +with `--types sqltypes` (see [`pkg/writers/bun`](../writers/bun/README.md) and +[`pkg/writers/gorm`](../writers/gorm/README.md)). It can also be imported +directly in hand-written models. + +## Import + +```go +import sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes" +``` + +## Scalar types + +All scalar types are instantiations of the generic `SqlNull[T]`: + +| Type | Underlying | Typical SQL type | +|---|---|---| +| `SqlInt16` | `int16` | `smallint` | +| `SqlInt32` | `int32` | `integer` | +| `SqlInt64` | `int64` | `bigint` | +| `SqlFloat32` | `float32` | `real`, `float4` | +| `SqlFloat64` | `float64` | `double precision`, `numeric`, `decimal`, `money` | +| `SqlBool` | `bool` | `boolean` | +| `SqlString` | `string` | `text`, `varchar`, `char`, `citext`, `inet`, `cidr`, `macaddr` | +| `SqlByteArray` | `[]byte` | `bytea` (base64-encoded in JSON/YAML/XML) | +| `SqlUUID` | `uuid.UUID` (`github.com/google/uuid`) | `uuid` | + +You can also instantiate `SqlNull[T]` directly for any type not covered +above, e.g. `SqlNull[MyEnum]`. + +### Date/time types + +Plain `time.Time` doesn't distinguish date-only, time-only, and timestamp +semantics, and its zero value marshals to a confusing `0001-01-01T00:00:00Z`. +These wrapper types fix both problems: + +| Type | Format | Notes | +|---|---|---| +| `SqlTimeStamp` | `2006-01-02T15:04:05` | Full timestamp | +| `SqlDate` | `2006-01-02` | Date only | +| `SqlTime` | `15:04:05` | Time only | + +Zero/pre-epoch values (`time.Time{}` or anything before `0002-01-01`) marshal +to `null` and `Value()` returns `nil`, instead of leaking Go's zero-time +sentinel into the database or API responses. + +### JSON types + +| Type | Underlying | Notes | +|---|---|---| +| `SqlJSONB` | `[]byte` | Raw JSON bytes; `MarshalYAML` decodes to native YAML mappings/sequences instead of an embedded JSON string | +| `SqlJSON` | `= SqlJSONB` | Alias — PostgreSQL's `json` and `jsonb` share the same Go representation | + +`SqlJSONB` has `AsMap()` / `AsSlice()` helpers for pulling out +`map[string]any` / `[]any` without a separate `json.Unmarshal` call. + +### Vector type (pgvector) + +`SqlVector` wraps `[]float32` for the `vector` column type ([pgvector](https://github.com/pgvector/pgvector)), +scanning/writing the `[1,2,3]` literal format pgvector uses over the wire. + +## Array types + +PostgreSQL array columns (`text[]`, `integer[]`, …) map to `SqlXxxArray` +types, each wrapping `Val []T` + `Valid bool` and handling PostgreSQL's +`{a,b,c}` array literal format on `Scan`/`Value`: + +`SqlStringArray`, `SqlInt16Array`, `SqlInt32Array`, `SqlInt64Array`, +`SqlFloat32Array`, `SqlFloat64Array`, `SqlBoolArray`, `SqlUUIDArray`. + +## Constructing values + +Every type has a `NewSqlXxx(v)` constructor that sets `Valid: true`: + +```go +name := sql_types.NewSqlString("Ada Lovelace") +age := sql_types.NewSqlInt32(36) +tags := sql_types.NewSqlStringArray([]string{"engineer", "mathematician"}) +``` + +The zero value of any type (`sql_types.SqlString{}`) is null/invalid — use it +directly for a `NULL` field instead of a separate constructor. + +Generic helpers: + +```go +sql_types.Null(v, valid) // SqlNull[T]{Val: v, Valid: valid} +sql_types.NewSql[T](anyValue) // best-effort conversion from any Go value +``` + +## Reading values back + +Each scalar type has typed accessors that return the zero value instead of +panicking when `Valid` is false: + +```go +n.Int64() // SqlInt16/32/64, SqlFloat32/64, SqlBool, SqlString → int64 +n.Float64() // → float64 +n.Bool() // → bool +n.Time() // SqlNull[time.Time]-based types → time.Time +n.UUID() // SqlUUID → uuid.UUID +n.String() // fmt.Stringer — empty string when invalid +``` + +## Example + +```go +type User struct { + ID sql_types.SqlUUID `json:"id"` + Name sql_types.SqlString `json:"name"` + Tags sql_types.SqlStringArray `json:"tags"` + Metadata sql_types.SqlJSONB `json:"metadata"` + CreatedAt sql_types.SqlTimeStamp `json:"created_at"` +} + +u := User{ + ID: sql_types.NewSqlUUID(uuid.New()), + Name: sql_types.NewSqlString("Ada Lovelace"), + Tags: sql_types.NewSqlStringArray([]string{"engineer"}), + CreatedAt: sql_types.SqlTimeStampNow(), +} +// Metadata left as the zero value → serializes as null, scans as NULL. +``` + +Every type implements `sql.Scanner` and `driver.Valuer`, so these fields can +be used directly as struct fields with `database/sql`, `bun`, or `gorm` +without additional tags or hooks. diff --git a/sql_array_types.go b/sql_array_types.go new file mode 100644 index 0000000..2b0816f --- /dev/null +++ b/sql_array_types.go @@ -0,0 +1,1059 @@ +package sqltypes + +import ( + "database/sql/driver" + "encoding/json" + "encoding/xml" + "fmt" + "strconv" + "strings" + + "github.com/google/uuid" + "gopkg.in/yaml.v3" +) + +// marshalYAMLSlice returns the value used by yaml.Marshaler implementations +// for the nullable array types below: nil when invalid, the slice otherwise. +func marshalYAMLSlice[T any](valid bool, vals []T) (any, error) { + if !valid { + return nil, nil + } + return vals, nil +} + +// unmarshalYAMLSlice returns the value used by yaml.Unmarshaler +// implementations for the nullable array types below. +func unmarshalYAMLSlice[T any](value *yaml.Node) (vals []T, valid bool, err error) { + if value == nil || value.Tag == "!!null" { + return nil, false, nil + } + if err := value.Decode(&vals); err != nil { + return nil, false, err + } + return vals, true, nil +} + +// xmlArrayItemName is the element name used for each item when a nullable +// array type is encoded as XML, since XML has no native list type. +var xmlArrayItemName = xml.Name{Local: "item"} + +// marshalXMLSlice writes a nullable array type as XML: an empty element when +// invalid, otherwise the start tag followed by one child per element. +func marshalXMLSlice[T any](e *xml.Encoder, start xml.StartElement, valid bool, vals []T) error { + if !valid { + return e.EncodeElement("", start) + } + if err := e.EncodeToken(start); err != nil { + return err + } + for _, v := range vals { + if err := e.EncodeElement(v, xml.StartElement{Name: xmlArrayItemName}); err != nil { + return err + } + } + return e.EncodeToken(start.End()) +} + +// unmarshalXMLSlice reads back the XML written by marshalXMLSlice. +// +// XML has no native null representation: an element with no children +// unmarshals to a valid, empty slice rather than an invalid one. +func unmarshalXMLSlice[T any](d *xml.Decoder, start xml.StartElement) ([]T, error) { + var vals []T + for { + tok, err := d.Token() + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + var v T + if err := d.DecodeElement(&v, &t); err != nil { + return nil, err + } + vals = append(vals, v) + case xml.EndElement: + if t.Name == start.Name { + return vals, nil + } + } + } +} + +// parsePostgresArrayElements parses a PostgreSQL array literal (e.g. `{a,"b,c",d}`) +// into a slice of raw string elements. Each element retains its unquoted/unescaped value. +func parsePostgresArrayElements(s string) ([]string, error) { + s = strings.TrimSpace(s) + if s == "" || strings.EqualFold(s, "null") || strings.EqualFold(s, "NULL") { + return nil, nil + } + if !strings.HasPrefix(s, "{") || !strings.HasSuffix(s, "}") { + return nil, fmt.Errorf("not a valid PostgreSQL array literal: %q", s) + } + inner := s[1 : len(s)-1] + if inner == "" { + return []string{}, nil + } + + var result []string + var cur strings.Builder + inQuotes := false + i := 0 + for i < len(inner) { + c := inner[i] + switch { + case c == '"' && !inQuotes: + inQuotes = true + case c == '"' && inQuotes: + if i+1 < len(inner) && inner[i+1] == '"' { + cur.WriteByte('"') + i++ + } else { + inQuotes = false + } + case c == '\\' && inQuotes: + if i+1 < len(inner) { + cur.WriteByte(inner[i+1]) + i++ + } + case c == ',' && !inQuotes: + result = append(result, cur.String()) + cur.Reset() + default: + cur.WriteByte(c) + } + i++ + } + result = append(result, cur.String()) + return result, nil +} + +// formatPostgresStringArray formats a []string back into a PostgreSQL array literal. +func formatPostgresStringArray(vals []string) string { + if vals == nil { + return "NULL" + } + parts := make([]string, len(vals)) + for i, v := range vals { + // Quote if value contains comma, double-quote, backslash, braces, whitespace, or is empty. + needsQuote := v == "" || strings.ContainsAny(v, `,"\\{}`+"\t\n\r ") + if needsQuote { + v = strings.ReplaceAll(v, `\`, `\\`) + v = strings.ReplaceAll(v, `"`, `""`) + parts[i] = `"` + v + `"` + } else { + parts[i] = v + } + } + return "{" + strings.Join(parts, ",") + "}" +} + +// ── SqlStringArray ─────────────────────────────────────────────────────────── + +// SqlStringArray is a nullable PostgreSQL text[] / varchar[] array. +type SqlStringArray struct { + Val []string + Valid bool +} + +func (a *SqlStringArray) Scan(value any) error { + if value == nil { + a.Valid = false + a.Val = nil + return nil + } + var s string + switch v := value.(type) { + case string: + s = v + case []byte: + s = string(v) + default: + return fmt.Errorf("SqlStringArray: cannot scan type %T", value) + } + elems, err := parsePostgresArrayElements(s) + if err != nil { + return err + } + a.Val = elems + a.Valid = true + return nil +} + +func (a SqlStringArray) Value() (driver.Value, error) { + if !a.Valid { + return nil, nil + } + return formatPostgresStringArray(a.Val), nil +} + +func (a SqlStringArray) MarshalJSON() ([]byte, error) { + if !a.Valid { + return []byte("null"), nil + } + return json.Marshal(a.Val) +} + +func (a *SqlStringArray) UnmarshalJSON(b []byte) error { + s := strings.TrimSpace(string(b)) + if s == "null" { + a.Valid = false + a.Val = nil + return nil + } + var vals []string + if err := json.Unmarshal(b, &vals); err != nil { + return err + } + a.Val = vals + a.Valid = true + return nil +} + +func (a SqlStringArray) MarshalYAML() (any, error) { + return marshalYAMLSlice(a.Valid, a.Val) +} + +func (a *SqlStringArray) UnmarshalYAML(value *yaml.Node) error { + vals, valid, err := unmarshalYAMLSlice[string](value) + if err != nil { + return err + } + a.Val, a.Valid = vals, valid + return nil +} + +func (a SqlStringArray) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return marshalXMLSlice(e, start, a.Valid, a.Val) +} + +func (a *SqlStringArray) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + vals, err := unmarshalXMLSlice[string](d, start) + if err != nil { + return err + } + a.Val, a.Valid = vals, true + return nil +} + +func NewSqlStringArray(v []string) SqlStringArray { + return SqlStringArray{Val: v, Valid: true} +} + +// ── SqlInt16Array ──────────────────────────────────────────────────────────── + +type SqlInt16Array struct { + Val []int16 + Valid bool +} + +func (a *SqlInt16Array) Scan(value any) error { + if value == nil { + a.Valid = false + a.Val = nil + return nil + } + var s string + switch v := value.(type) { + case string: + s = v + case []byte: + s = string(v) + default: + return fmt.Errorf("SqlInt16Array: cannot scan type %T", value) + } + elems, err := parsePostgresArrayElements(s) + if err != nil { + return err + } + a.Val = make([]int16, len(elems)) + for i, e := range elems { + n, err := strconv.ParseInt(strings.TrimSpace(e), 10, 16) + if err != nil { + return fmt.Errorf("SqlInt16Array: element %d %q: %w", i, e, err) + } + a.Val[i] = int16(n) + } + a.Valid = true + return nil +} + +func (a SqlInt16Array) Value() (driver.Value, error) { + if !a.Valid { + return nil, nil + } + parts := make([]string, len(a.Val)) + for i, v := range a.Val { + parts[i] = strconv.FormatInt(int64(v), 10) + } + return "{" + strings.Join(parts, ",") + "}", nil +} + +func (a SqlInt16Array) MarshalJSON() ([]byte, error) { + if !a.Valid { + return []byte("null"), nil + } + return json.Marshal(a.Val) +} + +func (a *SqlInt16Array) UnmarshalJSON(b []byte) error { + if strings.TrimSpace(string(b)) == "null" { + a.Valid = false + a.Val = nil + return nil + } + var vals []int16 + if err := json.Unmarshal(b, &vals); err != nil { + return err + } + a.Val = vals + a.Valid = true + return nil +} + +func (a SqlInt16Array) MarshalYAML() (any, error) { + return marshalYAMLSlice(a.Valid, a.Val) +} + +func (a *SqlInt16Array) UnmarshalYAML(value *yaml.Node) error { + vals, valid, err := unmarshalYAMLSlice[int16](value) + if err != nil { + return err + } + a.Val, a.Valid = vals, valid + return nil +} + +func (a SqlInt16Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return marshalXMLSlice(e, start, a.Valid, a.Val) +} + +func (a *SqlInt16Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + vals, err := unmarshalXMLSlice[int16](d, start) + if err != nil { + return err + } + a.Val, a.Valid = vals, true + return nil +} + +func NewSqlInt16Array(v []int16) SqlInt16Array { + return SqlInt16Array{Val: v, Valid: true} +} + +// ── SqlInt32Array ──────────────────────────────────────────────────────────── + +type SqlInt32Array struct { + Val []int32 + Valid bool +} + +func (a *SqlInt32Array) Scan(value any) error { + if value == nil { + a.Valid = false + a.Val = nil + return nil + } + var s string + switch v := value.(type) { + case string: + s = v + case []byte: + s = string(v) + default: + return fmt.Errorf("SqlInt32Array: cannot scan type %T", value) + } + elems, err := parsePostgresArrayElements(s) + if err != nil { + return err + } + a.Val = make([]int32, len(elems)) + for i, e := range elems { + n, err := strconv.ParseInt(strings.TrimSpace(e), 10, 32) + if err != nil { + return fmt.Errorf("SqlInt32Array: element %d %q: %w", i, e, err) + } + a.Val[i] = int32(n) + } + a.Valid = true + return nil +} + +func (a SqlInt32Array) Value() (driver.Value, error) { + if !a.Valid { + return nil, nil + } + parts := make([]string, len(a.Val)) + for i, v := range a.Val { + parts[i] = strconv.FormatInt(int64(v), 10) + } + return "{" + strings.Join(parts, ",") + "}", nil +} + +func (a SqlInt32Array) MarshalJSON() ([]byte, error) { + if !a.Valid { + return []byte("null"), nil + } + return json.Marshal(a.Val) +} + +func (a *SqlInt32Array) UnmarshalJSON(b []byte) error { + if strings.TrimSpace(string(b)) == "null" { + a.Valid = false + a.Val = nil + return nil + } + var vals []int32 + if err := json.Unmarshal(b, &vals); err != nil { + return err + } + a.Val = vals + a.Valid = true + return nil +} + +func (a SqlInt32Array) MarshalYAML() (any, error) { + return marshalYAMLSlice(a.Valid, a.Val) +} + +func (a *SqlInt32Array) UnmarshalYAML(value *yaml.Node) error { + vals, valid, err := unmarshalYAMLSlice[int32](value) + if err != nil { + return err + } + a.Val, a.Valid = vals, valid + return nil +} + +func (a SqlInt32Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return marshalXMLSlice(e, start, a.Valid, a.Val) +} + +func (a *SqlInt32Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + vals, err := unmarshalXMLSlice[int32](d, start) + if err != nil { + return err + } + a.Val, a.Valid = vals, true + return nil +} + +func NewSqlInt32Array(v []int32) SqlInt32Array { + return SqlInt32Array{Val: v, Valid: true} +} + +// ── SqlInt64Array ──────────────────────────────────────────────────────────── + +type SqlInt64Array struct { + Val []int64 + Valid bool +} + +func (a *SqlInt64Array) Scan(value any) error { + if value == nil { + a.Valid = false + a.Val = nil + return nil + } + var s string + switch v := value.(type) { + case string: + s = v + case []byte: + s = string(v) + default: + return fmt.Errorf("SqlInt64Array: cannot scan type %T", value) + } + elems, err := parsePostgresArrayElements(s) + if err != nil { + return err + } + a.Val = make([]int64, len(elems)) + for i, e := range elems { + n, err := strconv.ParseInt(strings.TrimSpace(e), 10, 64) + if err != nil { + return fmt.Errorf("SqlInt64Array: element %d %q: %w", i, e, err) + } + a.Val[i] = n + } + a.Valid = true + return nil +} + +func (a SqlInt64Array) Value() (driver.Value, error) { + if !a.Valid { + return nil, nil + } + parts := make([]string, len(a.Val)) + for i, v := range a.Val { + parts[i] = strconv.FormatInt(v, 10) + } + return "{" + strings.Join(parts, ",") + "}", nil +} + +func (a SqlInt64Array) MarshalJSON() ([]byte, error) { + if !a.Valid { + return []byte("null"), nil + } + return json.Marshal(a.Val) +} + +func (a *SqlInt64Array) UnmarshalJSON(b []byte) error { + if strings.TrimSpace(string(b)) == "null" { + a.Valid = false + a.Val = nil + return nil + } + var vals []int64 + if err := json.Unmarshal(b, &vals); err != nil { + return err + } + a.Val = vals + a.Valid = true + return nil +} + +func (a SqlInt64Array) MarshalYAML() (any, error) { + return marshalYAMLSlice(a.Valid, a.Val) +} + +func (a *SqlInt64Array) UnmarshalYAML(value *yaml.Node) error { + vals, valid, err := unmarshalYAMLSlice[int64](value) + if err != nil { + return err + } + a.Val, a.Valid = vals, valid + return nil +} + +func (a SqlInt64Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return marshalXMLSlice(e, start, a.Valid, a.Val) +} + +func (a *SqlInt64Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + vals, err := unmarshalXMLSlice[int64](d, start) + if err != nil { + return err + } + a.Val, a.Valid = vals, true + return nil +} + +func NewSqlInt64Array(v []int64) SqlInt64Array { + return SqlInt64Array{Val: v, Valid: true} +} + +// ── SqlFloat32Array ────────────────────────────────────────────────────────── + +type SqlFloat32Array struct { + Val []float32 + Valid bool +} + +func (a *SqlFloat32Array) Scan(value any) error { + if value == nil { + a.Valid = false + a.Val = nil + return nil + } + var s string + switch v := value.(type) { + case string: + s = v + case []byte: + s = string(v) + default: + return fmt.Errorf("SqlFloat32Array: cannot scan type %T", value) + } + elems, err := parsePostgresArrayElements(s) + if err != nil { + return err + } + a.Val = make([]float32, len(elems)) + for i, e := range elems { + f, err := strconv.ParseFloat(strings.TrimSpace(e), 32) + if err != nil { + return fmt.Errorf("SqlFloat32Array: element %d %q: %w", i, e, err) + } + a.Val[i] = float32(f) + } + a.Valid = true + return nil +} + +func (a SqlFloat32Array) Value() (driver.Value, error) { + if !a.Valid { + return nil, nil + } + parts := make([]string, len(a.Val)) + for i, v := range a.Val { + parts[i] = strconv.FormatFloat(float64(v), 'f', -1, 32) + } + return "{" + strings.Join(parts, ",") + "}", nil +} + +func (a SqlFloat32Array) MarshalJSON() ([]byte, error) { + if !a.Valid { + return []byte("null"), nil + } + return json.Marshal(a.Val) +} + +func (a *SqlFloat32Array) UnmarshalJSON(b []byte) error { + if strings.TrimSpace(string(b)) == "null" { + a.Valid = false + a.Val = nil + return nil + } + var vals []float32 + if err := json.Unmarshal(b, &vals); err != nil { + return err + } + a.Val = vals + a.Valid = true + return nil +} + +func (a SqlFloat32Array) MarshalYAML() (any, error) { + return marshalYAMLSlice(a.Valid, a.Val) +} + +func (a *SqlFloat32Array) UnmarshalYAML(value *yaml.Node) error { + vals, valid, err := unmarshalYAMLSlice[float32](value) + if err != nil { + return err + } + a.Val, a.Valid = vals, valid + return nil +} + +func (a SqlFloat32Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return marshalXMLSlice(e, start, a.Valid, a.Val) +} + +func (a *SqlFloat32Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + vals, err := unmarshalXMLSlice[float32](d, start) + if err != nil { + return err + } + a.Val, a.Valid = vals, true + return nil +} + +func NewSqlFloat32Array(v []float32) SqlFloat32Array { + return SqlFloat32Array{Val: v, Valid: true} +} + +// ── SqlFloat64Array ────────────────────────────────────────────────────────── + +type SqlFloat64Array struct { + Val []float64 + Valid bool +} + +func (a *SqlFloat64Array) Scan(value any) error { + if value == nil { + a.Valid = false + a.Val = nil + return nil + } + var s string + switch v := value.(type) { + case string: + s = v + case []byte: + s = string(v) + default: + return fmt.Errorf("SqlFloat64Array: cannot scan type %T", value) + } + elems, err := parsePostgresArrayElements(s) + if err != nil { + return err + } + a.Val = make([]float64, len(elems)) + for i, e := range elems { + f, err := strconv.ParseFloat(strings.TrimSpace(e), 64) + if err != nil { + return fmt.Errorf("SqlFloat64Array: element %d %q: %w", i, e, err) + } + a.Val[i] = f + } + a.Valid = true + return nil +} + +func (a SqlFloat64Array) Value() (driver.Value, error) { + if !a.Valid { + return nil, nil + } + parts := make([]string, len(a.Val)) + for i, v := range a.Val { + parts[i] = strconv.FormatFloat(v, 'f', -1, 64) + } + return "{" + strings.Join(parts, ",") + "}", nil +} + +func (a SqlFloat64Array) MarshalJSON() ([]byte, error) { + if !a.Valid { + return []byte("null"), nil + } + return json.Marshal(a.Val) +} + +func (a *SqlFloat64Array) UnmarshalJSON(b []byte) error { + if strings.TrimSpace(string(b)) == "null" { + a.Valid = false + a.Val = nil + return nil + } + var vals []float64 + if err := json.Unmarshal(b, &vals); err != nil { + return err + } + a.Val = vals + a.Valid = true + return nil +} + +func (a SqlFloat64Array) MarshalYAML() (any, error) { + return marshalYAMLSlice(a.Valid, a.Val) +} + +func (a *SqlFloat64Array) UnmarshalYAML(value *yaml.Node) error { + vals, valid, err := unmarshalYAMLSlice[float64](value) + if err != nil { + return err + } + a.Val, a.Valid = vals, valid + return nil +} + +func (a SqlFloat64Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return marshalXMLSlice(e, start, a.Valid, a.Val) +} + +func (a *SqlFloat64Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + vals, err := unmarshalXMLSlice[float64](d, start) + if err != nil { + return err + } + a.Val, a.Valid = vals, true + return nil +} + +func NewSqlFloat64Array(v []float64) SqlFloat64Array { + return SqlFloat64Array{Val: v, Valid: true} +} + +// ── SqlBoolArray ───────────────────────────────────────────────────────────── + +type SqlBoolArray struct { + Val []bool + Valid bool +} + +func (a *SqlBoolArray) Scan(value any) error { + if value == nil { + a.Valid = false + a.Val = nil + return nil + } + var s string + switch v := value.(type) { + case string: + s = v + case []byte: + s = string(v) + default: + return fmt.Errorf("SqlBoolArray: cannot scan type %T", value) + } + elems, err := parsePostgresArrayElements(s) + if err != nil { + return err + } + a.Val = make([]bool, len(elems)) + for i, e := range elems { + e = strings.ToLower(strings.TrimSpace(e)) + a.Val[i] = e == "t" || e == "true" || e == "1" || e == "yes" + } + a.Valid = true + return nil +} + +func (a SqlBoolArray) Value() (driver.Value, error) { + if !a.Valid { + return nil, nil + } + parts := make([]string, len(a.Val)) + for i, v := range a.Val { + if v { + parts[i] = "t" + } else { + parts[i] = "f" + } + } + return "{" + strings.Join(parts, ",") + "}", nil +} + +func (a SqlBoolArray) MarshalJSON() ([]byte, error) { + if !a.Valid { + return []byte("null"), nil + } + return json.Marshal(a.Val) +} + +func (a *SqlBoolArray) UnmarshalJSON(b []byte) error { + if strings.TrimSpace(string(b)) == "null" { + a.Valid = false + a.Val = nil + return nil + } + var vals []bool + if err := json.Unmarshal(b, &vals); err != nil { + return err + } + a.Val = vals + a.Valid = true + return nil +} + +func (a SqlBoolArray) MarshalYAML() (any, error) { + return marshalYAMLSlice(a.Valid, a.Val) +} + +func (a *SqlBoolArray) UnmarshalYAML(value *yaml.Node) error { + vals, valid, err := unmarshalYAMLSlice[bool](value) + if err != nil { + return err + } + a.Val, a.Valid = vals, valid + return nil +} + +func (a SqlBoolArray) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return marshalXMLSlice(e, start, a.Valid, a.Val) +} + +func (a *SqlBoolArray) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + vals, err := unmarshalXMLSlice[bool](d, start) + if err != nil { + return err + } + a.Val, a.Valid = vals, true + return nil +} + +func NewSqlBoolArray(v []bool) SqlBoolArray { + return SqlBoolArray{Val: v, Valid: true} +} + +// ── SqlUUIDArray ───────────────────────────────────────────────────────────── + +type SqlUUIDArray struct { + Val []uuid.UUID + Valid bool +} + +func (a *SqlUUIDArray) Scan(value any) error { + if value == nil { + a.Valid = false + a.Val = nil + return nil + } + var s string + switch v := value.(type) { + case string: + s = v + case []byte: + s = string(v) + default: + return fmt.Errorf("SqlUUIDArray: cannot scan type %T", value) + } + elems, err := parsePostgresArrayElements(s) + if err != nil { + return err + } + a.Val = make([]uuid.UUID, len(elems)) + for i, e := range elems { + u, err := uuid.Parse(strings.TrimSpace(e)) + if err != nil { + return fmt.Errorf("SqlUUIDArray: element %d %q: %w", i, e, err) + } + a.Val[i] = u + } + a.Valid = true + return nil +} + +func (a SqlUUIDArray) Value() (driver.Value, error) { + if !a.Valid { + return nil, nil + } + parts := make([]string, len(a.Val)) + for i, v := range a.Val { + parts[i] = v.String() + } + return "{" + strings.Join(parts, ",") + "}", nil +} + +func (a SqlUUIDArray) MarshalJSON() ([]byte, error) { + if !a.Valid { + return []byte("null"), nil + } + return json.Marshal(a.Val) +} + +func (a *SqlUUIDArray) UnmarshalJSON(b []byte) error { + if strings.TrimSpace(string(b)) == "null" { + a.Valid = false + a.Val = nil + return nil + } + var vals []uuid.UUID + if err := json.Unmarshal(b, &vals); err != nil { + return err + } + a.Val = vals + a.Valid = true + return nil +} + +func (a SqlUUIDArray) MarshalYAML() (any, error) { + return marshalYAMLSlice(a.Valid, a.Val) +} + +func (a *SqlUUIDArray) UnmarshalYAML(value *yaml.Node) error { + vals, valid, err := unmarshalYAMLSlice[uuid.UUID](value) + if err != nil { + return err + } + a.Val, a.Valid = vals, valid + return nil +} + +func (a SqlUUIDArray) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return marshalXMLSlice(e, start, a.Valid, a.Val) +} + +func (a *SqlUUIDArray) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + vals, err := unmarshalXMLSlice[uuid.UUID](d, start) + if err != nil { + return err + } + a.Val, a.Valid = vals, true + return nil +} + +func NewSqlUUIDArray(v []uuid.UUID) SqlUUIDArray { + return SqlUUIDArray{Val: v, Valid: true} +} + +// ── SqlVector ──────────────────────────────────────────────────────────────── + +// SqlVector is a nullable pgvector `vector` type backed by []float32. +// Wire format: `[1.0,2.0,3.0]` (square brackets, comma-separated floats). +type SqlVector struct { + Val []float32 + Valid bool +} + +func (v *SqlVector) Scan(value any) error { + if value == nil { + v.Valid = false + v.Val = nil + return nil + } + var s string + switch val := value.(type) { + case string: + s = val + case []byte: + s = string(val) + default: + return fmt.Errorf("SqlVector: cannot scan type %T", value) + } + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") { + return fmt.Errorf("SqlVector: not a valid vector literal: %q", s) + } + inner := s[1 : len(s)-1] + if inner == "" { + v.Val = []float32{} + v.Valid = true + return nil + } + parts := strings.Split(inner, ",") + v.Val = make([]float32, len(parts)) + for i, p := range parts { + f, err := strconv.ParseFloat(strings.TrimSpace(p), 32) + if err != nil { + return fmt.Errorf("SqlVector: element %d %q: %w", i, p, err) + } + v.Val[i] = float32(f) + } + v.Valid = true + return nil +} + +func (v SqlVector) Value() (driver.Value, error) { + if !v.Valid { + return nil, nil + } + parts := make([]string, len(v.Val)) + for i, f := range v.Val { + parts[i] = strconv.FormatFloat(float64(f), 'f', -1, 32) + } + return "[" + strings.Join(parts, ",") + "]", nil +} + +func (v SqlVector) MarshalJSON() ([]byte, error) { + if !v.Valid { + return []byte("null"), nil + } + return json.Marshal(v.Val) +} + +func (v *SqlVector) UnmarshalJSON(b []byte) error { + if strings.TrimSpace(string(b)) == "null" { + v.Valid = false + v.Val = nil + return nil + } + var vals []float32 + if err := json.Unmarshal(b, &vals); err != nil { + return err + } + v.Val = vals + v.Valid = true + return nil +} + +func (v SqlVector) MarshalYAML() (any, error) { + return marshalYAMLSlice(v.Valid, v.Val) +} + +func (v *SqlVector) UnmarshalYAML(value *yaml.Node) error { + vals, valid, err := unmarshalYAMLSlice[float32](value) + if err != nil { + return err + } + v.Val, v.Valid = vals, valid + return nil +} + +func (v SqlVector) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return marshalXMLSlice(e, start, v.Valid, v.Val) +} + +func (v *SqlVector) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + vals, err := unmarshalXMLSlice[float32](d, start) + if err != nil { + return err + } + v.Val, v.Valid = vals, true + return nil +} + +func NewSqlVector(val []float32) SqlVector { + return SqlVector{Val: val, Valid: true} +} diff --git a/sql_array_types_test.go b/sql_array_types_test.go new file mode 100644 index 0000000..5691f24 --- /dev/null +++ b/sql_array_types_test.go @@ -0,0 +1,485 @@ +package sqltypes + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" +) + +func TestParsePostgresArrayElements(t *testing.T) { + tests := []struct { + name string + input string + want []string + wantErr bool + }{ + {"simple", "{a,b,c}", []string{"a", "b", "c"}, false}, + {"empty array", "{}", []string{}, false}, + {"null", "NULL", nil, false}, + {"lowercase null", "null", nil, false}, + {"empty string", "", nil, false}, + {"quoted with comma", `{a,"b,c",d}`, []string{"a", "b,c", "d"}, false}, + {"escaped quote", `{"a""b"}`, []string{`a"b`}, false}, + {"escaped backslash", `{"a\\b"}`, []string{`a\b`}, false}, + {"not an array", "abc", nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parsePostgresArrayElements(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(tt.want) { + t.Fatalf("expected %v, got %v", tt.want, got) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("index %d: expected %q, got %q", i, tt.want[i], got[i]) + } + } + }) + } +} + +func TestFormatPostgresStringArray(t *testing.T) { + tests := []struct { + name string + input []string + want string + }{ + {"nil", nil, "NULL"}, + {"empty", []string{}, "{}"}, + {"simple", []string{"a", "b"}, "{a,b}"}, + {"needs quoting comma", []string{"a,b"}, `{"a,b"}`}, + {"needs quoting empty elem", []string{""}, `{""}`}, + {"needs quoting quote", []string{`a"b`}, `{"a""b"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatPostgresStringArray(tt.input) + if got != tt.want { + t.Errorf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestSqlStringArray(t *testing.T) { + t.Run("scan and value round-trip", func(t *testing.T) { + var a SqlStringArray + if err := a.Scan(`{a,"b,c",d}`); err != nil { + t.Fatalf("Scan failed: %v", err) + } + want := []string{"a", "b,c", "d"} + if len(a.Val) != len(want) { + t.Fatalf("expected %v, got %v", want, a.Val) + } + for i := range want { + if a.Val[i] != want[i] { + t.Errorf("index %d: expected %q, got %q", i, want[i], a.Val[i]) + } + } + + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + var b SqlStringArray + if err := b.Scan(val); err != nil { + t.Fatalf("re-scan failed: %v", err) + } + for i := range want { + if b.Val[i] != want[i] { + t.Errorf("round-trip index %d: expected %q, got %q", i, want[i], b.Val[i]) + } + } + }) + + t.Run("scan nil", func(t *testing.T) { + var a SqlStringArray + if err := a.Scan(nil); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if a.Valid { + t.Error("expected invalid") + } + }) + + t.Run("value invalid", func(t *testing.T) { + a := SqlStringArray{Valid: false} + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + if val != nil { + t.Errorf("expected nil, got %v", val) + } + }) + + t.Run("scan wrong type", func(t *testing.T) { + var a SqlStringArray + if err := a.Scan(42); err == nil { + t.Error("expected error for unsupported scan type") + } + }) + + t.Run("json round-trip", func(t *testing.T) { + a := NewSqlStringArray([]string{"x", "y", "z"}) + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != `["x","y","z"]` { + t.Errorf("unexpected JSON: %s", data) + } + var a2 SqlStringArray + if err := json.Unmarshal(data, &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if !a2.Valid || len(a2.Val) != 3 { + t.Fatalf("expected 3 valid elements, got %v", a2) + } + }) + + t.Run("json null", func(t *testing.T) { + var a SqlStringArray + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "null" { + t.Errorf("expected null, got %s", data) + } + var a2 SqlStringArray + if err := json.Unmarshal([]byte("null"), &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if a2.Valid { + t.Error("expected invalid after unmarshaling null") + } + }) + + t.Run("json unmarshal invalid type errors", func(t *testing.T) { + var a SqlStringArray + if err := json.Unmarshal([]byte(`42`), &a); err == nil { + t.Error("expected error unmarshaling non-array JSON") + } + }) +} + +func TestSqlInt16Array(t *testing.T) { + t.Run("scan and value", func(t *testing.T) { + var a SqlInt16Array + if err := a.Scan("{1,2,-3}"); err != nil { + t.Fatalf("Scan failed: %v", err) + } + want := []int16{1, 2, -3} + for i := range want { + if a.Val[i] != want[i] { + t.Errorf("index %d: expected %d, got %d", i, want[i], a.Val[i]) + } + } + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + if val != "{1,2,-3}" { + t.Errorf("expected {1,2,-3}, got %v", val) + } + }) + + t.Run("scan invalid element", func(t *testing.T) { + var a SqlInt16Array + if err := a.Scan("{1,abc}"); err == nil { + t.Error("expected error for non-numeric element") + } + }) + + t.Run("json round-trip", func(t *testing.T) { + a := NewSqlInt16Array([]int16{5, 10, 15}) + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var a2 SqlInt16Array + if err := json.Unmarshal(data, &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, v := range a.Val { + if a2.Val[i] != v { + t.Errorf("index %d: expected %d, got %d", i, v, a2.Val[i]) + } + } + }) +} + +func TestSqlInt32Array(t *testing.T) { + a := NewSqlInt32Array([]int32{100000, -200000}) + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + var b SqlInt32Array + if err := b.Scan(val); err != nil { + t.Fatalf("Scan failed: %v", err) + } + for i, v := range a.Val { + if b.Val[i] != v { + t.Errorf("index %d: expected %d, got %d", i, v, b.Val[i]) + } + } + + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var c SqlInt32Array + if err := json.Unmarshal(data, &c); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, v := range a.Val { + if c.Val[i] != v { + t.Errorf("index %d: expected %d, got %d", i, v, c.Val[i]) + } + } +} + +func TestSqlInt64Array(t *testing.T) { + a := NewSqlInt64Array([]int64{9223372036854775807, -9223372036854775808}) + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + var b SqlInt64Array + if err := b.Scan(val); err != nil { + t.Fatalf("Scan failed: %v", err) + } + for i, v := range a.Val { + if b.Val[i] != v { + t.Errorf("index %d: expected %d, got %d", i, v, b.Val[i]) + } + } + + t.Run("null json", func(t *testing.T) { + var n SqlInt64Array + data, _ := json.Marshal(n) + if string(data) != "null" { + t.Errorf("expected null, got %s", data) + } + }) +} + +func TestSqlFloat32Array(t *testing.T) { + a := NewSqlFloat32Array([]float32{1.5, -2.25, 0}) + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + var b SqlFloat32Array + if err := b.Scan(val); err != nil { + t.Fatalf("Scan failed: %v", err) + } + for i, v := range a.Val { + if b.Val[i] != v { + t.Errorf("index %d: expected %v, got %v", i, v, b.Val[i]) + } + } + + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var c SqlFloat32Array + if err := json.Unmarshal(data, &c); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, v := range a.Val { + if c.Val[i] != v { + t.Errorf("index %d: expected %v, got %v", i, v, c.Val[i]) + } + } +} + +func TestSqlFloat64Array(t *testing.T) { + a := NewSqlFloat64Array([]float64{3.14159, -2.71828}) + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + var b SqlFloat64Array + if err := b.Scan(val); err != nil { + t.Fatalf("Scan failed: %v", err) + } + for i, v := range a.Val { + if b.Val[i] != v { + t.Errorf("index %d: expected %v, got %v", i, v, b.Val[i]) + } + } +} + +func TestSqlBoolArray(t *testing.T) { + t.Run("scan various truthy forms", func(t *testing.T) { + var a SqlBoolArray + if err := a.Scan("{t,f,true,false,1,0,yes}"); err != nil { + t.Fatalf("Scan failed: %v", err) + } + want := []bool{true, false, true, false, true, false, true} + for i := range want { + if a.Val[i] != want[i] { + t.Errorf("index %d: expected %v, got %v", i, want[i], a.Val[i]) + } + } + }) + + t.Run("value formatting", func(t *testing.T) { + a := NewSqlBoolArray([]bool{true, false}) + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + if val != "{t,f}" { + t.Errorf("expected {t,f}, got %v", val) + } + }) + + t.Run("json round-trip", func(t *testing.T) { + a := NewSqlBoolArray([]bool{true, false, true}) + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var a2 SqlBoolArray + if err := json.Unmarshal(data, &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, v := range a.Val { + if a2.Val[i] != v { + t.Errorf("index %d: expected %v, got %v", i, v, a2.Val[i]) + } + } + }) +} + +func TestSqlUUIDArray(t *testing.T) { + u1, u2 := uuid.New(), uuid.New() + + t.Run("scan and value round-trip", func(t *testing.T) { + a := NewSqlUUIDArray([]uuid.UUID{u1, u2}) + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + var b SqlUUIDArray + if err := b.Scan(val); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if b.Val[0] != u1 || b.Val[1] != u2 { + t.Errorf("expected [%v %v], got %v", u1, u2, b.Val) + } + }) + + t.Run("scan invalid uuid element", func(t *testing.T) { + var a SqlUUIDArray + if err := a.Scan("{not-a-uuid}"); err == nil { + t.Error("expected error for invalid uuid element") + } + }) + + t.Run("json round-trip", func(t *testing.T) { + a := NewSqlUUIDArray([]uuid.UUID{u1, u2}) + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var a2 SqlUUIDArray + if err := json.Unmarshal(data, &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if a2.Val[0] != u1 || a2.Val[1] != u2 { + t.Errorf("expected [%v %v], got %v", u1, u2, a2.Val) + } + }) +} + +func TestSqlVector(t *testing.T) { + t.Run("scan and value round-trip", func(t *testing.T) { + var v SqlVector + if err := v.Scan("[1,2.5,-3]"); err != nil { + t.Fatalf("Scan failed: %v", err) + } + want := []float32{1, 2.5, -3} + for i := range want { + if v.Val[i] != want[i] { + t.Errorf("index %d: expected %v, got %v", i, want[i], v.Val[i]) + } + } + val, err := v.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + if val != "[1,2.5,-3]" { + t.Errorf("expected [1,2.5,-3], got %v", val) + } + }) + + t.Run("scan empty vector", func(t *testing.T) { + var v SqlVector + if err := v.Scan("[]"); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if !v.Valid || len(v.Val) != 0 { + t.Errorf("expected valid empty vector, got %v", v) + } + }) + + t.Run("scan invalid literal", func(t *testing.T) { + var v SqlVector + if err := v.Scan("not-a-vector"); err == nil { + t.Error("expected error for invalid vector literal") + } + }) + + t.Run("json round-trip", func(t *testing.T) { + v := NewSqlVector([]float32{0.1, 0.2, 0.3}) + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var v2 SqlVector + if err := json.Unmarshal(data, &v2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, f := range v.Val { + if v2.Val[i] != f { + t.Errorf("index %d: expected %v, got %v", i, f, v2.Val[i]) + } + } + }) + + t.Run("json null", func(t *testing.T) { + var v SqlVector + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "null" { + t.Errorf("expected null, got %s", data) + } + var v2 SqlVector + if err := json.Unmarshal([]byte("null"), &v2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if v2.Valid { + t.Error("expected invalid after unmarshaling null") + } + }) +} diff --git a/sql_types.go b/sql_types.go new file mode 100644 index 0000000..0b3146c --- /dev/null +++ b/sql_types.go @@ -0,0 +1,981 @@ +// Package sqltypes provides nullable SQL types with automatic casting and conversion methods. +package sqltypes + +import ( + "database/sql" + "database/sql/driver" + "encoding/base64" + "encoding/json" + "encoding/xml" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "gopkg.in/yaml.v3" +) + +// tryParseDT attempts to parse a string into a time.Time using various formats. +func tryParseDT(str string) (time.Time, error) { + var lasterror error + tryFormats := []string{ + time.RFC3339, + "2006-01-02T15:04:05.000-0700", + "2006-01-02T15:04:05.000", + "06-01-02T15:04:05.000", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "02/01/2006", + "02-01-2006", + "2006-01-02", + "15:04:05.000", + "15:04:05", + "15:04", + } + for _, f := range tryFormats { + tx, err := time.Parse(f, str) + if err == nil { + return tx, nil + } + lasterror = err + } + return time.Time{}, lasterror // Return zero time on failure +} + +// ToJSONDT formats a time.Time to RFC3339 string. +func ToJSONDT(dt time.Time) string { + return dt.Format(time.RFC3339) +} + +// SqlNull is a generic nullable type that behaves like sql.NullXXX with auto-casting. +type SqlNull[T any] struct { + Val T + Valid bool +} + +// Scan implements sql.Scanner. +func (n *SqlNull[T]) Scan(value any) error { + if value == nil { + n.Valid = false + n.Val = *new(T) + return nil + } + + // Check if T is []byte, and decode base64 if applicable + // Do this BEFORE trying sql.Null to ensure base64 is handled + var zero T + if _, ok := any(zero).([]byte); ok { + // For []byte types, try to decode from base64 + var strVal string + switch v := value.(type) { + case string: + strVal = v + case []byte: + strVal = string(v) + default: + strVal = fmt.Sprintf("%v", value) + } + // Try base64 decode + if decoded, err := base64.StdEncoding.DecodeString(strVal); err == nil { + n.Val = any(decoded).(T) + n.Valid = true + return nil + } + // Fallback to raw bytes + n.Val = any([]byte(strVal)).(T) + n.Valid = true + return nil + } + + // Try standard sql.Null[T] for other types. + var sqlNull sql.Null[T] + if err := sqlNull.Scan(value); err == nil { + n.Val = sqlNull.V + n.Valid = sqlNull.Valid + return nil + } + + // Fallback: parse from string/bytes. + switch v := value.(type) { + case string: + return n.FromString(v) + case []byte: + return n.FromString(string(v)) + case float32, float64: + return n.FromString(fmt.Sprintf("%f", value)) + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return n.FromString(fmt.Sprintf("%d", value)) + default: + return n.FromString(fmt.Sprintf("%v", value)) + } +} +func (n *SqlNull[T]) FromString(s string) error { + s = strings.TrimSpace(s) + n.Valid = false + n.Val = *new(T) + + if s == "" || strings.EqualFold(s, "null") { + return nil + } + + var zero T + switch any(zero).(type) { + case int, int8, int16, int32, int64: + if i, err := strconv.ParseInt(s, 10, 64); err == nil { + reflect.ValueOf(&n.Val).Elem().SetInt(i) + n.Valid = true + } else if f, err := strconv.ParseFloat(s, 64); err == nil { + reflect.ValueOf(&n.Val).Elem().SetInt(int64(f)) + n.Valid = true + } + case uint, uint8, uint16, uint32, uint64: + if u, err := strconv.ParseUint(s, 10, 64); err == nil { + reflect.ValueOf(&n.Val).Elem().SetUint(u) + n.Valid = true + } else if f, err := strconv.ParseFloat(s, 64); err == nil && f >= 0 { + reflect.ValueOf(&n.Val).Elem().SetUint(uint64(f)) + n.Valid = true + } + case float32, float64: + if f, err := strconv.ParseFloat(s, 64); err == nil { + reflect.ValueOf(&n.Val).Elem().SetFloat(f) + n.Valid = true + } + case bool: + if b, err := strconv.ParseBool(s); err == nil { + n.Val = any(b).(T) + n.Valid = true + } + case time.Time: + if t, err := tryParseDT(s); err == nil && !t.IsZero() { + n.Val = any(t).(T) + n.Valid = true + } + case uuid.UUID: + if u, err := uuid.Parse(s); err == nil { + n.Val = any(u).(T) + n.Valid = true + } + case []byte: + n.Val = any([]byte(s)).(T) + n.Valid = true + case string: + n.Val = any(s).(T) + n.Valid = true + } + return nil +} + +// Value implements driver.Valuer. +func (n SqlNull[T]) Value() (driver.Value, error) { + if !n.Valid { + return nil, nil + } + + // Check if the type implements fmt.Stringer (e.g., uuid.UUID, custom types) + // Convert to string for driver compatibility + if stringer, ok := any(n.Val).(fmt.Stringer); ok { + return stringer.String(), nil + } + + return any(n.Val), nil +} + +// MarshalJSON implements json.Marshaler. +func (n SqlNull[T]) MarshalJSON() ([]byte, error) { + if !n.Valid { + return []byte("null"), nil + } + + // Check if T is []byte, and encode to base64 + if _, ok := any(n.Val).([]byte); ok { + // Encode []byte as base64 + encoded := base64.StdEncoding.EncodeToString(any(n.Val).([]byte)) + return json.Marshal(encoded) + } + + return json.Marshal(n.Val) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (n *SqlNull[T]) UnmarshalJSON(b []byte) error { + if len(b) == 0 || string(b) == "null" || strings.TrimSpace(string(b)) == "" { + n.Valid = false + n.Val = *new(T) + return nil + } + + // Check if T is []byte, and decode from base64 + var val T + if _, ok := any(val).([]byte); ok { + // Unmarshal as string first (JSON representation) + var s string + if err := json.Unmarshal(b, &s); err == nil { + // Decode from base64 + if decoded, err := base64.StdEncoding.DecodeString(s); err == nil { + n.Val = any(decoded).(T) + n.Valid = true + return nil + } + // Fallback to raw string as bytes + n.Val = any([]byte(s)).(T) + n.Valid = true + return nil + } + } + + if err := json.Unmarshal(b, &val); err == nil { + n.Val = val + n.Valid = true + return nil + } + + // Fallback: unmarshal as string and parse. + var s string + if err := json.Unmarshal(b, &s); err == nil { + return n.FromString(s) + } + + return fmt.Errorf("cannot unmarshal %s into SqlNull[%T]", b, n.Val) +} + +// MarshalYAML implements yaml.Marshaler. +func (n SqlNull[T]) MarshalYAML() (any, error) { + if !n.Valid { + return nil, nil + } + + // Check if T is []byte, and encode to base64 (mirrors MarshalJSON). + if b, ok := any(n.Val).([]byte); ok { + return base64.StdEncoding.EncodeToString(b), nil + } + + return n.Val, nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (n *SqlNull[T]) UnmarshalYAML(value *yaml.Node) error { + if value == nil || value.Tag == "!!null" { + n.Valid = false + n.Val = *new(T) + return nil + } + + // Check if T is []byte, and decode from base64. + var zero T + if _, ok := any(zero).([]byte); ok { + var s string + if err := value.Decode(&s); err == nil { + if decoded, err := base64.StdEncoding.DecodeString(s); err == nil { + n.Val = any(decoded).(T) + n.Valid = true + return nil + } + n.Val = any([]byte(s)).(T) + n.Valid = true + return nil + } + } + + var val T + if err := value.Decode(&val); err == nil { + n.Val = val + n.Valid = true + return nil + } + + // Fallback: decode as string and parse. + var s string + if err := value.Decode(&s); err == nil { + return n.FromString(s) + } + + return fmt.Errorf("cannot unmarshal %q into SqlNull[%T]", value.Value, n.Val) +} + +// MarshalXML implements xml.Marshaler. +func (n SqlNull[T]) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + if !n.Valid { + return e.EncodeElement("", start) + } + + // Check if T is []byte, and encode to base64 (mirrors MarshalJSON). + if b, ok := any(n.Val).([]byte); ok { + return e.EncodeElement(base64.StdEncoding.EncodeToString(b), start) + } + + return e.EncodeElement(n.Val, start) +} + +// UnmarshalXML implements xml.Unmarshaler. +// +// XML has no native null representation, so an empty element unmarshals to +// an invalid (null) value rather than a zero-value-but-valid one. +func (n *SqlNull[T]) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var s string + if err := d.DecodeElement(&s, &start); err != nil { + return err + } + if s == "" { + n.Valid = false + n.Val = *new(T) + return nil + } + + var zero T + if _, ok := any(zero).([]byte); ok { + if decoded, err := base64.StdEncoding.DecodeString(s); err == nil { + n.Val = any(decoded).(T) + n.Valid = true + return nil + } + n.Val = any([]byte(s)).(T) + n.Valid = true + return nil + } + + return n.FromString(s) +} + +// String implements fmt.Stringer. +func (n SqlNull[T]) String() string { + if !n.Valid { + return "" + } + // Check if the type implements fmt.Stringer for better string representation + if stringer, ok := any(n.Val).(fmt.Stringer); ok { + return stringer.String() + } + return fmt.Sprintf("%v", n.Val) +} + +// Int64 converts to int64 or 0 if invalid. +func (n SqlNull[T]) Int64() int64 { + if !n.Valid { + return 0 + } + v := reflect.ValueOf(any(n.Val)) + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return int64(v.Uint()) + case reflect.Float32, reflect.Float64: + return int64(v.Float()) + case reflect.String: + i, _ := strconv.ParseInt(v.String(), 10, 64) + return i + case reflect.Bool: + if v.Bool() { + return 1 + } + return 0 + } + return 0 +} + +// Float64 converts to float64 or 0.0 if invalid. +func (n SqlNull[T]) Float64() float64 { + if !n.Valid { + return 0.0 + } + v := reflect.ValueOf(any(n.Val)) + switch v.Kind() { + case reflect.Float32, reflect.Float64: + return v.Float() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return float64(v.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return float64(v.Uint()) + case reflect.String: + f, _ := strconv.ParseFloat(v.String(), 64) + return f + } + return 0.0 +} + +// Bool converts to bool or false if invalid. +func (n SqlNull[T]) Bool() bool { + if !n.Valid { + return false + } + v := reflect.ValueOf(any(n.Val)) + if v.Kind() == reflect.Bool { + return v.Bool() + } + s := strings.ToLower(strings.TrimSpace(fmt.Sprint(n.Val))) + return s == "true" || s == "t" || s == "1" || s == "yes" || s == "on" +} + +// Time converts to time.Time or zero if invalid. +func (n SqlNull[T]) Time() time.Time { + if !n.Valid { + return time.Time{} + } + if t, ok := any(n.Val).(time.Time); ok { + return t + } + return time.Time{} +} + +// UUID converts to uuid.UUID or Nil if invalid. +func (n SqlNull[T]) UUID() uuid.UUID { + if !n.Valid { + return uuid.Nil + } + if u, ok := any(n.Val).(uuid.UUID); ok { + return u + } + return uuid.Nil +} + +// Type aliases for common types. +type ( + SqlInt16 = SqlNull[int16] + SqlInt32 = SqlNull[int32] + SqlInt64 = SqlNull[int64] + SqlFloat32 = SqlNull[float32] + SqlFloat64 = SqlNull[float64] + SqlBool = SqlNull[bool] + SqlString = SqlNull[string] + SqlByteArray = SqlNull[[]byte] + SqlUUID = SqlNull[uuid.UUID] +) + +// SqlTimeStamp - Timestamp with custom formatting (YYYY-MM-DDTHH:MM:SS). +type SqlTimeStamp struct{ SqlNull[time.Time] } + +func (t SqlTimeStamp) MarshalJSON() ([]byte, error) { + if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) { + return []byte("null"), nil + } + return fmt.Appendf(nil, `"%s"`, t.Val.Format("2006-01-02T15:04:05")), nil +} + +func (t *SqlTimeStamp) UnmarshalJSON(b []byte) error { + if err := t.SqlNull.UnmarshalJSON(b); err != nil { + return err + } + if t.Valid && (t.Val.IsZero() || t.Val.Format("2006-01-02T15:04:05") == "0001-01-01T00:00:00") { + t.Valid = false + } + return nil +} + +func (t SqlTimeStamp) Value() (driver.Value, error) { + if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) { + return nil, nil + } + return t.Val.Format("2006-01-02T15:04:05"), nil +} + +func (t SqlTimeStamp) MarshalYAML() (any, error) { + if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) { + return nil, nil + } + return t.Val.Format("2006-01-02T15:04:05"), nil +} + +func (t *SqlTimeStamp) UnmarshalYAML(value *yaml.Node) error { + if err := t.SqlNull.UnmarshalYAML(value); err != nil { + return err + } + if t.Valid && (t.Val.IsZero() || t.Val.Format("2006-01-02T15:04:05") == "0001-01-01T00:00:00") { + t.Valid = false + } + return nil +} + +func (t SqlTimeStamp) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) { + return e.EncodeElement("", start) + } + return e.EncodeElement(t.Val.Format("2006-01-02T15:04:05"), start) +} + +func (t *SqlTimeStamp) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var s string + if err := d.DecodeElement(&s, &start); err != nil { + return err + } + if s == "" { + t.Valid = false + t.Val = time.Time{} + return nil + } + tm, err := tryParseDT(s) + if err != nil { + return err + } + t.Val = tm + t.Valid = !tm.IsZero() && tm.Format("2006-01-02T15:04:05") != "0001-01-01T00:00:00" + return nil +} + +func SqlTimeStampNow() SqlTimeStamp { + return SqlTimeStamp{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}} +} + +// SqlDate - Date only (YYYY-MM-DD). +type SqlDate struct{ SqlNull[time.Time] } + +func (d SqlDate) MarshalJSON() ([]byte, error) { + if !d.Valid || d.Val.IsZero() { + return []byte("null"), nil + } + s := d.Val.Format("2006-01-02") + if strings.HasPrefix(s, "0001-01-01") { + return []byte("null"), nil + } + return fmt.Appendf(nil, `"%s"`, s), nil +} + +func (d *SqlDate) UnmarshalJSON(b []byte) error { + if err := d.SqlNull.UnmarshalJSON(b); err != nil { + return err + } + if d.Valid && d.Val.Format("2006-01-02") <= "0001-01-01" { + d.Valid = false + } + return nil +} + +func (d SqlDate) Value() (driver.Value, error) { + if !d.Valid || d.Val.IsZero() { + return nil, nil + } + s := d.Val.Format("2006-01-02") + if s <= "0001-01-01" { + return nil, nil + } + return s, nil +} + +func (d SqlDate) String() string { + if !d.Valid { + return "" + } + s := d.Val.Format("2006-01-02") + if strings.HasPrefix(s, "0001-01-01") || strings.HasPrefix(s, "1800-12-31") { + return "" + } + return s +} + +func (d SqlDate) MarshalYAML() (any, error) { + if !d.Valid || d.Val.IsZero() { + return nil, nil + } + s := d.Val.Format("2006-01-02") + if strings.HasPrefix(s, "0001-01-01") { + return nil, nil + } + return s, nil +} + +func (d *SqlDate) UnmarshalYAML(value *yaml.Node) error { + if err := d.SqlNull.UnmarshalYAML(value); err != nil { + return err + } + if d.Valid && d.Val.Format("2006-01-02") <= "0001-01-01" { + d.Valid = false + } + return nil +} + +func (d SqlDate) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + if !d.Valid || d.Val.IsZero() { + return e.EncodeElement("", start) + } + s := d.Val.Format("2006-01-02") + if strings.HasPrefix(s, "0001-01-01") { + return e.EncodeElement("", start) + } + return e.EncodeElement(s, start) +} + +func (d *SqlDate) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error { + var s string + if err := dec.DecodeElement(&s, &start); err != nil { + return err + } + if s == "" { + d.Valid = false + d.Val = time.Time{} + return nil + } + tm, err := tryParseDT(s) + if err != nil { + return err + } + d.Val = tm + d.Valid = !tm.IsZero() && tm.Format("2006-01-02") > "0001-01-01" + return nil +} + +func SqlDateNow() SqlDate { + return SqlDate{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}} +} + +// SqlTime - Time only (HH:MM:SS). +type SqlTime struct{ SqlNull[time.Time] } + +func (t SqlTime) MarshalJSON() ([]byte, error) { + if !t.Valid || t.Val.IsZero() { + return []byte("null"), nil + } + s := t.Val.Format("15:04:05") + if s == "00:00:00" { + return []byte("null"), nil + } + return fmt.Appendf(nil, `"%s"`, s), nil +} + +func (t *SqlTime) UnmarshalJSON(b []byte) error { + if err := t.SqlNull.UnmarshalJSON(b); err != nil { + return err + } + if t.Valid && t.Val.Format("15:04:05") == "00:00:00" { + t.Valid = false + } + return nil +} + +func (t SqlTime) Value() (driver.Value, error) { + if !t.Valid || t.Val.IsZero() { + return nil, nil + } + return t.Val.Format("15:04:05"), nil +} + +func (t SqlTime) String() string { + if !t.Valid { + return "" + } + return t.Val.Format("15:04:05") +} + +func (t SqlTime) MarshalYAML() (any, error) { + if !t.Valid || t.Val.IsZero() { + return nil, nil + } + s := t.Val.Format("15:04:05") + if s == "00:00:00" { + return nil, nil + } + return s, nil +} + +func (t *SqlTime) UnmarshalYAML(value *yaml.Node) error { + if err := t.SqlNull.UnmarshalYAML(value); err != nil { + return err + } + if t.Valid && t.Val.Format("15:04:05") == "00:00:00" { + t.Valid = false + } + return nil +} + +func (t SqlTime) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + if !t.Valid || t.Val.IsZero() { + return e.EncodeElement("", start) + } + s := t.Val.Format("15:04:05") + if s == "00:00:00" { + return e.EncodeElement("", start) + } + return e.EncodeElement(s, start) +} + +func (t *SqlTime) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var s string + if err := d.DecodeElement(&s, &start); err != nil { + return err + } + if s == "" { + t.Valid = false + t.Val = time.Time{} + return nil + } + tm, err := tryParseDT(s) + if err != nil { + return err + } + t.Val = tm + t.Valid = !tm.IsZero() && tm.Format("15:04:05") != "00:00:00" + return nil +} + +func SqlTimeNow() SqlTime { + return SqlTime{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}} +} + +// SqlJSONB - Nullable JSONB as []byte. +type SqlJSONB []byte + +// SqlJSON - Nullable JSON as []byte. PostgreSQL's json and jsonb types share +// the same textual representation and Go marshalling behavior, differing only +// in server-side storage, so SqlJSON is an alias of SqlJSONB. +type SqlJSON = SqlJSONB + +// Scan implements sql.Scanner. +func (n *SqlJSONB) Scan(value any) error { + if value == nil { + *n = nil + return nil + } + switch v := value.(type) { + case string: + *n = []byte(v) + case []byte: + *n = v + default: + dat, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("failed to marshal value to JSON: %v", err) + } + *n = dat + } + return nil +} + +// Value implements driver.Valuer. +func (n SqlJSONB) Value() (driver.Value, error) { + if len(n) == 0 { + return nil, nil + } + var js any + if err := json.Unmarshal(n, &js); err != nil { + return nil, fmt.Errorf("invalid JSON: %v", err) + } + return string(n), nil +} + +// MarshalJSON implements json.Marshaler. +func (n SqlJSONB) MarshalJSON() ([]byte, error) { + if len(n) == 0 { + return []byte("null"), nil + } + var obj any + if err := json.Unmarshal(n, &obj); err != nil { + return []byte("null"), nil + } + return n, nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (n *SqlJSONB) UnmarshalJSON(b []byte) error { + s := strings.TrimSpace(string(b)) + if s == "null" || s == "" || (!strings.HasPrefix(s, "{") && !strings.HasPrefix(s, "[")) { + *n = nil + return nil + } + *n = b + return nil +} + +// MarshalYAML implements yaml.Marshaler. The underlying JSON is decoded into +// a generic value first so it renders as native YAML mappings/sequences +// rather than an embedded JSON string. +func (n SqlJSONB) MarshalYAML() (any, error) { + if len(n) == 0 { + return nil, nil + } + var v any + if err := json.Unmarshal(n, &v); err != nil { + return nil, nil + } + return v, nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (n *SqlJSONB) UnmarshalYAML(value *yaml.Node) error { + if value == nil || value.Tag == "!!null" { + *n = nil + return nil + } + var v any + if err := value.Decode(&v); err != nil { + return err + } + b, err := json.Marshal(v) + if err != nil { + return err + } + *n = b + return nil +} + +// MarshalXML implements xml.Marshaler. JSON has no clean structural mapping +// to XML, so the raw JSON text is emitted as the element's text content. +func (n SqlJSONB) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + if len(n) == 0 { + return e.EncodeElement("", start) + } + var obj any + if err := json.Unmarshal(n, &obj); err != nil { + return e.EncodeElement("", start) + } + return e.EncodeElement(string(n), start) +} + +// UnmarshalXML implements xml.Unmarshaler, reading back the raw JSON text +// written by MarshalXML. +func (n *SqlJSONB) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var s string + if err := d.DecodeElement(&s, &start); err != nil { + return err + } + s = strings.TrimSpace(s) + if s == "" { + *n = nil + return nil + } + *n = []byte(s) + return nil +} + +func (n SqlJSONB) AsMap() (map[string]any, error) { + if len(n) == 0 { + return nil, nil + } + js := make(map[string]any) + if err := json.Unmarshal(n, &js); err != nil { + return nil, fmt.Errorf("invalid JSON: %v", err) + } + return js, nil +} + +func (n SqlJSONB) AsSlice() ([]any, error) { + if len(n) == 0 { + return nil, nil + } + js := make([]any, 0) + if err := json.Unmarshal(n, &js); err != nil { + return nil, fmt.Errorf("invalid JSON: %v", err) + } + return js, nil +} + +// TryIfInt64 tries to parse any value to int64 with default. +func TryIfInt64(v any, def int64) int64 { + switch val := v.(type) { + case string: + i, err := strconv.ParseInt(val, 10, 64) + if err != nil { + return def + } + return i + case int: + return int64(val) + case int8: + return int64(val) + case int16: + return int64(val) + case int32: + return int64(val) + case int64: + return val + case uint: + return int64(val) + case uint8: + return int64(val) + case uint16: + return int64(val) + case uint32: + return int64(val) + case uint64: + return int64(val) + case float32: + return int64(val) + case float64: + return int64(val) + case []byte: + i, err := strconv.ParseInt(string(val), 10, 64) + if err != nil { + return def + } + return i + default: + return def + } +} + +// Constructor helpers - clean and fast value creation +func Null[T any](v T, valid bool) SqlNull[T] { + return SqlNull[T]{Val: v, Valid: valid} +} + +func NewSql[T any](value any) SqlNull[T] { + n := SqlNull[T]{} + + if value == nil { + return n + } + + // Fast path: exact match + if v, ok := value.(T); ok { + n.Val = v + n.Valid = true + return n + } + + // Try from another SqlNull + if sn, ok := value.(SqlNull[T]); ok { + return sn + } + + // Convert via string + _ = n.FromString(fmt.Sprintf("%v", value)) + return n +} + +func NewSqlInt16(v int16) SqlInt16 { + return SqlInt16{Val: v, Valid: true} +} + +func NewSqlInt32(v int32) SqlInt32 { + return SqlInt32{Val: v, Valid: true} +} + +func NewSqlInt64(v int64) SqlInt64 { + return SqlInt64{Val: v, Valid: true} +} + +func NewSqlFloat32(v float32) SqlFloat32 { + return SqlFloat32{Val: v, Valid: true} +} + +func NewSqlFloat64(v float64) SqlFloat64 { + return SqlFloat64{Val: v, Valid: true} +} + +func NewSqlBool(v bool) SqlBool { + return SqlBool{Val: v, Valid: true} +} + +func NewSqlString(v string) SqlString { + return SqlString{Val: v, Valid: true} +} + +func NewSqlByteArray(v []byte) SqlByteArray { + return SqlByteArray{Val: v, Valid: true} +} + +func NewSqlUUID(v uuid.UUID) SqlUUID { + return SqlUUID{Val: v, Valid: true} +} + +func NewSqlTimeStamp(v time.Time) SqlTimeStamp { + return SqlTimeStamp{SqlNull: SqlNull[time.Time]{Val: v, Valid: true}} +} + +func NewSqlDate(v time.Time) SqlDate { + return SqlDate{SqlNull: SqlNull[time.Time]{Val: v, Valid: true}} +} + +func NewSqlTime(v time.Time) SqlTime { + return SqlTime{SqlNull: SqlNull[time.Time]{Val: v, Valid: true}} +} diff --git a/sql_types_fromstring_test.go b/sql_types_fromstring_test.go new file mode 100644 index 0000000..d8c536f --- /dev/null +++ b/sql_types_fromstring_test.go @@ -0,0 +1,134 @@ +package sqltypes + +import ( + "encoding/json" + "testing" +) + +// TestSqlNull_FromString_UnsignedInt guards against a regression where +// FromString called reflect.Value.SetInt on unsigned-kind fields, which +// panics since SetInt only accepts Int/Int8/.../Int64 kinds. +func TestSqlNull_FromString_UnsignedInt(t *testing.T) { + tests := []struct { + name string + input string + expected uint32 + }{ + {"simple", "123", 123}, + {"zero", "0", 0}, + {"large", "4000000000", 4000000000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var n SqlNull[uint32] + if err := n.FromString(tt.input); err != nil { + t.Fatalf("FromString failed: %v", err) + } + if !n.Valid { + t.Fatalf("expected valid=true") + } + if n.Val != tt.expected { + t.Errorf("expected %d, got %d", tt.expected, n.Val) + } + }) + } +} + +// TestSqlNull_FromString_Int64Precision guards against a regression where +// FromString unconditionally re-parsed the string as float64 after a +// successful ParseInt, corrupting large int64 values due to float rounding +// (e.g. math.MaxInt64 became negative). +func TestSqlNull_FromString_Int64Precision(t *testing.T) { + tests := []struct { + name string + input string + expected int64 + }{ + {"max int64", "9223372036854775807", 9223372036854775807}, + {"min int64", "-9223372036854775808", -9223372036854775808}, + {"large safe value", "1234567890123456", 1234567890123456}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var n SqlNull[int64] + if err := n.FromString(tt.input); err != nil { + t.Fatalf("FromString failed: %v", err) + } + if !n.Valid { + t.Fatalf("expected valid=true") + } + if n.Val != tt.expected { + t.Errorf("expected %d, got %d", tt.expected, n.Val) + } + }) + } +} + +// TestSqlNull_FromString_FloatFallback verifies that non-integral strings +// still parse via the float fallback path for integer-kind fields. +func TestSqlNull_FromString_FloatFallback(t *testing.T) { + var n SqlNull[int32] + if err := n.FromString("42.9"); err != nil { + t.Fatalf("FromString failed: %v", err) + } + if !n.Valid || n.Val != 42 { + t.Errorf("expected valid int32=42, got valid=%v val=%d", n.Valid, n.Val) + } + + var u SqlNull[uint32] + if err := u.FromString("42.9"); err != nil { + t.Fatalf("FromString failed: %v", err) + } + if !u.Valid || u.Val != 42 { + t.Errorf("expected valid uint32=42, got valid=%v val=%d", u.Valid, u.Val) + } + + var neg SqlNull[uint32] + if err := neg.FromString("-1.5"); err != nil { + t.Fatalf("FromString failed: %v", err) + } + if neg.Valid { + t.Errorf("expected invalid for negative value into unsigned type, got %v", neg.Val) + } +} + +// TestSqlNull_UnsignedTypes_ScanAndJSON exercises Scan and JSON round-trip +// for every unsigned alias to make sure none of them panic. +func TestSqlNull_UnsignedTypes_ScanAndJSON(t *testing.T) { + t.Run("uint8 scan from string", func(t *testing.T) { + var n SqlNull[uint8] + if err := n.Scan("200"); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if !n.Valid || n.Val != 200 { + t.Errorf("expected valid uint8=200, got valid=%v val=%d", n.Valid, n.Val) + } + }) + + t.Run("uint64 json round-trip", func(t *testing.T) { + n := Null(uint64(18446744073709551615), true) + data, err := json.Marshal(n) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var n2 SqlNull[uint64] + if err := json.Unmarshal(data, &n2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if n2.Val != n.Val { + t.Errorf("expected %d, got %d", n.Val, n2.Val) + } + }) + + t.Run("uint scan from numeric string fallback", func(t *testing.T) { + var n SqlNull[uint] + if err := n.Scan([]byte("77")); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if !n.Valid || n.Val != 77 { + t.Errorf("expected valid uint=77, got valid=%v val=%d", n.Valid, n.Val) + } + }) +} diff --git a/sql_types_test.go b/sql_types_test.go new file mode 100644 index 0000000..8f9bca2 --- /dev/null +++ b/sql_types_test.go @@ -0,0 +1,958 @@ +package sqltypes + +import ( + "database/sql/driver" + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" +) + +// TestNewSqlInt16 tests NewSqlInt16 type +func TestNewSqlInt16(t *testing.T) { + tests := []struct { + name string + input interface{} + expected SqlInt16 + }{ + {"int", 42, Null(int16(42), true)}, + {"int32", int32(100), NewSqlInt16(100)}, + {"int64", int64(200), NewSqlInt16(200)}, + {"string", "123", NewSqlInt16(123)}, + {"nil", nil, Null(int16(0), false)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var n SqlInt16 + if err := n.Scan(tt.input); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if n != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, n) + } + }) + } +} + +func TestNewSqlInt16_Value(t *testing.T) { + tests := []struct { + name string + input SqlInt16 + expected driver.Value + }{ + {"zero", Null(int16(0), false), nil}, + {"positive", NewSqlInt16(42), int16(42)}, + {"negative", NewSqlInt16(-10), int16(-10)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + val, err := tt.input.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + if val != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, val) + } + }) + } +} + +func TestNewSqlInt16_JSON(t *testing.T) { + n := NewSqlInt16(42) + + // Marshal + data, err := json.Marshal(n) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + expected := "42" + if string(data) != expected { + t.Errorf("expected %s, got %s", expected, string(data)) + } + + // Unmarshal + var n2 SqlInt16 + if err := json.Unmarshal([]byte("123"), &n2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if n2.Int64() != 123 { + t.Errorf("expected 123, got %d", n2.Int64()) + } +} + +// TestNewSqlInt64 tests NewSqlInt64 type +func TestNewSqlInt64(t *testing.T) { + tests := []struct { + name string + input interface{} + expected SqlInt64 + }{ + {"int", 42, NewSqlInt64(42)}, + {"int32", int32(100), NewSqlInt64(100)}, + {"int64", int64(9223372036854775807), NewSqlInt64(9223372036854775807)}, + {"uint32", uint32(100), NewSqlInt64(100)}, + {"uint64", uint64(200), NewSqlInt64(200)}, + {"nil", nil, SqlInt64{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var n SqlInt64 + if err := n.Scan(tt.input); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if n != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, n) + } + }) + } +} + +// TestSqlFloat64 tests SqlFloat64 type +func TestSqlFloat64(t *testing.T) { + tests := []struct { + name string + input interface{} + expected float64 + valid bool + }{ + {"float64", float64(3.14), 3.14, true}, + {"float32", float32(2.5), 2.5, true}, + {"int", 42, 42.0, true}, + {"int64", int64(100), 100.0, true}, + {"nil", nil, 0, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var n SqlFloat64 + if err := n.Scan(tt.input); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if n.Valid != tt.valid { + t.Errorf("expected valid=%v, got valid=%v", tt.valid, n.Valid) + } + if tt.valid && n.Float64() != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, n.Float64()) + } + }) + } +} + +// TestSqlTimeStamp tests SqlTimeStamp type +func TestSqlTimeStamp(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + input interface{} + }{ + {"time.Time", now}, + {"string RFC3339", now.Format(time.RFC3339)}, + {"string date", "2024-01-15"}, + {"string datetime", "2024-01-15T10:30:00"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ts SqlTimeStamp + if err := ts.Scan(tt.input); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if ts.Time().IsZero() { + t.Error("expected non-zero time") + } + }) + } +} + +func TestSqlTimeStamp_JSON(t *testing.T) { + now := time.Date(2024, 1, 15, 10, 30, 45, 0, time.UTC) + ts := NewSqlTimeStamp(now) + + // Marshal + data, err := json.Marshal(ts) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + expected := `"2024-01-15T10:30:45"` + if string(data) != expected { + t.Errorf("expected %s, got %s", expected, string(data)) + } + + // Unmarshal + var ts2 SqlTimeStamp + if err := json.Unmarshal([]byte(`"2024-01-15T10:30:45"`), &ts2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if ts2.Time().Year() != 2024 { + t.Errorf("expected year 2024, got %d", ts2.Time().Year()) + } + + // Test null + var ts3 SqlTimeStamp + if err := json.Unmarshal([]byte("null"), &ts3); err != nil { + t.Fatalf("Unmarshal null failed: %v", err) + } +} + +// TestSqlDate tests SqlDate type +func TestSqlDate(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + input interface{} + }{ + {"time.Time", now}, + {"string date", "2024-01-15"}, + {"string UK format", "15/01/2024"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var d SqlDate + if err := d.Scan(tt.input); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if d.String() == "0" { + t.Error("expected non-zero date") + } + }) + } +} + +func TestSqlDate_JSON(t *testing.T) { + date := NewSqlDate(time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)) + + // Marshal + data, err := json.Marshal(date) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + expected := `"2024-01-15"` + if string(data) != expected { + t.Errorf("expected %s, got %s", expected, string(data)) + } + + // Unmarshal + var d2 SqlDate + if err := json.Unmarshal([]byte(`"2024-01-15"`), &d2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } +} + +// TestSqlTime tests SqlTime type +func TestSqlTime(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + input interface{} + expected string + }{ + {"time.Time", now, now.Format("15:04:05")}, + {"string time", "10:30:45", "10:30:45"}, + {"string short time", "10:30", "10:30:00"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tm SqlTime + if err := tm.Scan(tt.input); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if tm.String() != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, tm.String()) + } + }) + } +} + +// TestSqlJSONB tests SqlJSONB type +func TestSqlJSONB_Scan(t *testing.T) { + tests := []struct { + name string + input interface{} + expected string + }{ + {"string JSON object", `{"key":"value"}`, `{"key":"value"}`}, + {"string JSON array", `[1,2,3]`, `[1,2,3]`}, + {"bytes", []byte(`{"test":true}`), `{"test":true}`}, + {"nil", nil, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var j SqlJSONB + if err := j.Scan(tt.input); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if tt.expected == "" && j == nil { + return // nil case + } + if string(j) != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, string(j)) + } + }) + } +} + +func TestSqlJSONB_Value(t *testing.T) { + tests := []struct { + name string + input SqlJSONB + expected string + wantErr bool + }{ + {"valid object", SqlJSONB(`{"key":"value"}`), `{"key":"value"}`, false}, + {"valid array", SqlJSONB(`[1,2,3]`), `[1,2,3]`, false}, + {"empty", SqlJSONB{}, "", false}, + {"nil", nil, "", false}, + {"invalid JSON", SqlJSONB(`{invalid`), "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + val, err := tt.input.Value() + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("Value failed: %v", err) + } + if tt.expected == "" && val == nil { + return // nil case + } + if val.(string) != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, val) + } + }) + } +} + +func TestSqlJSONB_JSON(t *testing.T) { + // Marshal + j := SqlJSONB(`{"name":"test","count":42}`) + data, err := json.Marshal(j) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("Unmarshal result failed: %v", err) + } + if result["name"] != "test" { + t.Errorf("expected name=test, got %v", result["name"]) + } + + // Unmarshal + var j2 SqlJSONB + if err := json.Unmarshal([]byte(`{"key":"value"}`), &j2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if string(j2) != `{"key":"value"}` { + t.Errorf("expected {\"key\":\"value\"}, got %s", string(j2)) + } + + // Test null + var j3 SqlJSONB + if err := json.Unmarshal([]byte("null"), &j3); err != nil { + t.Fatalf("Unmarshal null failed: %v", err) + } +} + +func TestSqlJSONB_AsMap(t *testing.T) { + tests := []struct { + name string + input SqlJSONB + wantErr bool + wantNil bool + }{ + {"valid object", SqlJSONB(`{"name":"test","age":30}`), false, false}, + {"empty", SqlJSONB{}, false, true}, + {"nil", nil, false, true}, + {"invalid JSON", SqlJSONB(`{invalid`), true, false}, + {"array not object", SqlJSONB(`[1,2,3]`), true, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, err := tt.input.AsMap() + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("AsMap failed: %v", err) + } + if tt.wantNil { + if m != nil { + t.Errorf("expected nil, got %v", m) + } + return + } + if m == nil { + t.Error("expected non-nil map") + } + }) + } +} + +func TestSqlJSONB_AsSlice(t *testing.T) { + tests := []struct { + name string + input SqlJSONB + wantErr bool + wantNil bool + }{ + {"valid array", SqlJSONB(`[1,2,3]`), false, false}, + {"empty", SqlJSONB{}, false, true}, + {"nil", nil, false, true}, + {"invalid JSON", SqlJSONB(`[invalid`), true, false}, + {"object not array", SqlJSONB(`{"key":"value"}`), true, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, err := tt.input.AsSlice() + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("AsSlice failed: %v", err) + } + if tt.wantNil { + if s != nil { + t.Errorf("expected nil, got %v", s) + } + return + } + if s == nil { + t.Error("expected non-nil slice") + } + }) + } +} + +// TestSqlUUID tests SqlUUID type +func TestSqlUUID_Scan(t *testing.T) { + testUUID := uuid.New() + testUUIDStr := testUUID.String() + + tests := []struct { + name string + input interface{} + expected string + valid bool + }{ + {"string UUID", testUUIDStr, testUUIDStr, true}, + {"bytes UUID", []byte(testUUIDStr), testUUIDStr, true}, + {"nil", nil, "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var u SqlUUID + if err := u.Scan(tt.input); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if u.Valid != tt.valid { + t.Errorf("expected valid=%v, got valid=%v", tt.valid, u.Valid) + } + if tt.valid && u.String() != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, u.String()) + } + }) + } +} + +func TestSqlUUID_Value(t *testing.T) { + testUUID := uuid.New() + u := NewSqlUUID(testUUID) + + val, err := u.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + // Value() should return a string for driver compatibility + if val != testUUID.String() { + t.Errorf("expected %s, got %s", testUUID.String(), val) + } + + // Test invalid UUID + u2 := SqlUUID{Valid: false} + val2, err := u2.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + if val2 != nil { + t.Errorf("expected nil, got %v", val2) + } +} + +func TestSqlUUID_JSON(t *testing.T) { + testUUID := uuid.New() + u := NewSqlUUID(testUUID) + + // Marshal + data, err := json.Marshal(u) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + expected := `"` + testUUID.String() + `"` + if string(data) != expected { + t.Errorf("expected %s, got %s", expected, string(data)) + } + + // Unmarshal + var u2 SqlUUID + if err := json.Unmarshal([]byte(`"`+testUUID.String()+`"`), &u2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if u2.String() != testUUID.String() { + t.Errorf("expected %s, got %s", testUUID.String(), u2.String()) + } + + // Test null + var u3 SqlUUID + if err := json.Unmarshal([]byte("null"), &u3); err != nil { + t.Fatalf("Unmarshal null failed: %v", err) + } + if u3.Valid { + t.Error("expected invalid UUID") + } +} + +// TestTryIfInt64 tests the TryIfInt64 helper function +func TestTryIfInt64(t *testing.T) { + tests := []struct { + name string + input interface{} + def int64 + expected int64 + }{ + {"string valid", "123", 0, 123}, + {"string invalid", "abc", 99, 99}, + {"int", 42, 0, 42}, + {"int32", int32(100), 0, 100}, + {"int64", int64(200), 0, 200}, + {"uint32", uint32(50), 0, 50}, + {"uint64", uint64(75), 0, 75}, + {"float32", float32(3.14), 0, 3}, + {"float64", float64(2.71), 0, 2}, + {"bytes", []byte("456"), 0, 456}, + {"unknown type", struct{}{}, 999, 999}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TryIfInt64(tt.input, tt.def) + if result != tt.expected { + t.Errorf("expected %d, got %d", tt.expected, result) + } + }) + } +} + +// TestSqlString tests SqlString without base64 (plain text) +func TestSqlString_Scan(t *testing.T) { + tests := []struct { + name string + input interface{} + expected string + valid bool + }{ + { + name: "plain string", + input: "hello world", + expected: "hello world", + valid: true, + }, + { + name: "plain text", + input: "plain text", + expected: "plain text", + valid: true, + }, + { + name: "bytes as string", + input: []byte("raw bytes"), + expected: "raw bytes", + valid: true, + }, + { + name: "nil value", + input: nil, + expected: "", + valid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var s SqlString + if err := s.Scan(tt.input); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if s.Valid != tt.valid { + t.Errorf("expected valid=%v, got valid=%v", tt.valid, s.Valid) + } + if tt.valid && s.String() != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, s.String()) + } + }) + } +} + +func TestSqlString_JSON(t *testing.T) { + tests := []struct { + name string + inputValue string + expectedJSON string + expectedDecode string + }{ + { + name: "simple string", + inputValue: "hello world", + expectedJSON: `"hello world"`, // plain text, not base64 + expectedDecode: "hello world", + }, + { + name: "special characters", + inputValue: "test@#$%", + expectedJSON: `"test@#$%"`, // plain text, not base64 + expectedDecode: "test@#$%", + }, + { + name: "unicode string", + inputValue: "Hello 世界", + expectedJSON: `"Hello 世界"`, // plain text, not base64 + expectedDecode: "Hello 世界", + }, + { + name: "empty string", + inputValue: "", + expectedJSON: `""`, + expectedDecode: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test MarshalJSON + s := NewSqlString(tt.inputValue) + data, err := json.Marshal(s) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != tt.expectedJSON { + t.Errorf("Marshal: expected %s, got %s", tt.expectedJSON, string(data)) + } + + // Test UnmarshalJSON + var s2 SqlString + if err := json.Unmarshal(data, &s2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if !s2.Valid { + t.Error("expected valid=true after unmarshal") + } + if s2.String() != tt.expectedDecode { + t.Errorf("Unmarshal: expected %q, got %q", tt.expectedDecode, s2.String()) + } + }) + } +} + +func TestSqlString_JSON_Null(t *testing.T) { + // Test null handling + var s SqlString + if err := json.Unmarshal([]byte("null"), &s); err != nil { + t.Fatalf("Unmarshal null failed: %v", err) + } + if s.Valid { + t.Error("expected invalid after unmarshaling null") + } + + // Test marshal null + data, err := json.Marshal(s) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "null" { + t.Errorf("expected null, got %s", string(data)) + } +} + +// TestSqlByteArray_Base64 tests SqlByteArray with base64 encoding/decoding +func TestSqlByteArray_Base64_Scan(t *testing.T) { + tests := []struct { + name string + input interface{} + expected []byte + valid bool + }{ + { + name: "base64 encoded bytes from SQL", + input: "aGVsbG8gd29ybGQ=", // "hello world" in base64 + expected: []byte("hello world"), + valid: true, + }, + { + name: "plain bytes fallback", + input: "plain text", + expected: []byte("plain text"), + valid: true, + }, + { + name: "bytes base64 encoded", + input: []byte("SGVsbG8gR29waGVy"), // "Hello Gopher" in base64 + expected: []byte("Hello Gopher"), + valid: true, + }, + { + name: "bytes plain fallback", + input: []byte("raw bytes"), + expected: []byte("raw bytes"), + valid: true, + }, + { + name: "binary data", + input: "AQIDBA==", // []byte{1, 2, 3, 4} in base64 + expected: []byte{1, 2, 3, 4}, + valid: true, + }, + { + name: "nil value", + input: nil, + expected: nil, + valid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var b SqlByteArray + if err := b.Scan(tt.input); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if b.Valid != tt.valid { + t.Errorf("expected valid=%v, got valid=%v", tt.valid, b.Valid) + } + if tt.valid { + if string(b.Val) != string(tt.expected) { + t.Errorf("expected %q, got %q", tt.expected, b.Val) + } + } + }) + } +} + +func TestSqlByteArray_Base64_JSON(t *testing.T) { + tests := []struct { + name string + inputValue []byte + expectedJSON string + expectedDecode []byte + }{ + { + name: "text bytes", + inputValue: []byte("hello world"), + expectedJSON: `"aGVsbG8gd29ybGQ="`, // base64 encoded + expectedDecode: []byte("hello world"), + }, + { + name: "binary data", + inputValue: []byte{0x01, 0x02, 0x03, 0x04, 0xFF}, + expectedJSON: `"AQIDBP8="`, // base64 encoded + expectedDecode: []byte{0x01, 0x02, 0x03, 0x04, 0xFF}, + }, + { + name: "empty bytes", + inputValue: []byte{}, + expectedJSON: `""`, // base64 of empty bytes + expectedDecode: []byte{}, + }, + { + name: "unicode bytes", + inputValue: []byte("Hello 世界"), + expectedJSON: `"SGVsbG8g5LiW55WM"`, // base64 encoded + expectedDecode: []byte("Hello 世界"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test MarshalJSON + b := NewSqlByteArray(tt.inputValue) + data, err := json.Marshal(b) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != tt.expectedJSON { + t.Errorf("Marshal: expected %s, got %s", tt.expectedJSON, string(data)) + } + + // Test UnmarshalJSON + var b2 SqlByteArray + if err := json.Unmarshal(data, &b2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if !b2.Valid { + t.Error("expected valid=true after unmarshal") + } + if string(b2.Val) != string(tt.expectedDecode) { + t.Errorf("Unmarshal: expected %v, got %v", tt.expectedDecode, b2.Val) + } + }) + } +} + +func TestSqlByteArray_Base64_JSON_Null(t *testing.T) { + // Test null handling + var b SqlByteArray + if err := json.Unmarshal([]byte("null"), &b); err != nil { + t.Fatalf("Unmarshal null failed: %v", err) + } + if b.Valid { + t.Error("expected invalid after unmarshaling null") + } + + // Test marshal null + data, err := json.Marshal(b) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "null" { + t.Errorf("expected null, got %s", string(data)) + } +} + +func TestSqlByteArray_Value(t *testing.T) { + tests := []struct { + name string + input SqlByteArray + expected interface{} + }{ + { + name: "valid bytes", + input: NewSqlByteArray([]byte("test data")), + expected: []byte("test data"), + }, + { + name: "empty bytes", + input: NewSqlByteArray([]byte{}), + expected: []byte{}, + }, + { + name: "invalid", + input: SqlByteArray{Valid: false}, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + val, err := tt.input.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + if tt.expected == nil && val != nil { + t.Errorf("expected nil, got %v", val) + } + if tt.expected != nil && val == nil { + t.Errorf("expected %v, got nil", tt.expected) + } + if tt.expected != nil && val != nil { + if string(val.([]byte)) != string(tt.expected.([]byte)) { + t.Errorf("expected %v, got %v", tt.expected, val) + } + } + }) + } +} + +// TestSqlString_RoundTrip tests complete round-trip: Go -> JSON -> Go -> SQL -> Go +func TestSqlString_RoundTrip(t *testing.T) { + original := "Test String with Special Chars: @#$%^&*()" + + // Go -> JSON + s1 := NewSqlString(original) + jsonData, err := json.Marshal(s1) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // JSON -> Go + var s2 SqlString + if err := json.Unmarshal(jsonData, &s2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + // Go -> SQL (Value) + _, err = s2.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + + // SQL -> Go (Scan plain text) + var s3 SqlString + // Simulate SQL driver returning plain text value + if err := s3.Scan(original); err != nil { + t.Fatalf("Scan failed: %v", err) + } + + // Verify round-trip + if s3.String() != original { + t.Errorf("Round-trip failed: expected %q, got %q", original, s3.String()) + } +} + +// TestSqlByteArray_Base64_RoundTrip tests complete round-trip: Go -> JSON -> Go -> SQL -> Go +func TestSqlByteArray_Base64_RoundTrip(t *testing.T) { + original := []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0xFF, 0xFE} // "Hello " + binary data + + // Go -> JSON + b1 := NewSqlByteArray(original) + jsonData, err := json.Marshal(b1) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // JSON -> Go + var b2 SqlByteArray + if err := json.Unmarshal(jsonData, &b2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + // Go -> SQL (Value) + _, err = b2.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + + // SQL -> Go (Scan with base64) + var b3 SqlByteArray + // Simulate SQL driver returning base64 encoded value + if err := b3.Scan("SGVsbG8g//4="); err != nil { + t.Fatalf("Scan failed: %v", err) + } + + // Verify round-trip + if string(b3.Val) != string(original) { + t.Errorf("Round-trip failed: expected %v, got %v", original, b3.Val) + } +} + diff --git a/sql_types_yaml_xml_test.go b/sql_types_yaml_xml_test.go new file mode 100644 index 0000000..098bb8b --- /dev/null +++ b/sql_types_yaml_xml_test.go @@ -0,0 +1,678 @@ +package sqltypes + +import ( + "encoding/xml" + "testing" + "time" + + "github.com/google/uuid" + "gopkg.in/yaml.v3" +) + +// ── SqlNull: YAML ──────────────────────────────────────────────────────────── + +func TestSqlNull_YAML_Int(t *testing.T) { + n := NewSqlInt32(42) + data, err := yaml.Marshal(n) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "42\n" { + t.Errorf("expected \"42\\n\", got %q", string(data)) + } + + var n2 SqlInt32 + if err := yaml.Unmarshal(data, &n2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if n2.Int64() != 42 { + t.Errorf("expected 42, got %d", n2.Int64()) + } +} + +func TestSqlNull_YAML_Null(t *testing.T) { + var n SqlInt32 + data, err := yaml.Marshal(n) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "null\n" { + t.Errorf("expected \"null\\n\", got %q", string(data)) + } + + var n2 SqlInt32 + if err := yaml.Unmarshal([]byte("null\n"), &n2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if n2.Valid { + t.Error("expected invalid after unmarshaling null") + } + + // ~ is also a YAML null. + var n3 SqlInt32 + if err := yaml.Unmarshal([]byte("~\n"), &n3); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if n3.Valid { + t.Error("expected invalid after unmarshaling ~") + } +} + +func TestSqlNull_YAML_String(t *testing.T) { + s := NewSqlString("hello world") + data, err := yaml.Marshal(s) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var s2 SqlString + if err := yaml.Unmarshal(data, &s2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if s2.String() != "hello world" { + t.Errorf("expected %q, got %q", "hello world", s2.String()) + } +} + +func TestSqlNull_YAML_UUID(t *testing.T) { + id := uuid.New() + u := NewSqlUUID(id) + data, err := yaml.Marshal(u) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != id.String()+"\n" { + t.Errorf("expected %q, got %q", id.String()+"\n", string(data)) + } + var u2 SqlUUID + if err := yaml.Unmarshal(data, &u2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if u2.UUID() != id { + t.Errorf("expected %v, got %v", id, u2.UUID()) + } +} + +func TestSqlNull_YAML_ByteArray_Base64(t *testing.T) { + orig := []byte{0x01, 0x02, 0xFF} + b := NewSqlByteArray(orig) + data, err := yaml.Marshal(b) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var b2 SqlByteArray + if err := yaml.Unmarshal(data, &b2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if string(b2.Val) != string(orig) { + t.Errorf("expected %v, got %v", orig, b2.Val) + } +} + +// ── SqlNull: XML ───────────────────────────────────────────────────────────── + +type xmlIntWrapper struct { + XMLName xml.Name `xml:"root"` + Value SqlInt32 `xml:"value"` +} + +func TestSqlNull_XML_Int(t *testing.T) { + w := xmlIntWrapper{Value: NewSqlInt32(99)} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlIntWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if w2.Value.Int64() != 99 { + t.Errorf("expected 99, got %d", w2.Value.Int64()) + } +} + +func TestSqlNull_XML_Null(t *testing.T) { + w := xmlIntWrapper{} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlIntWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if w2.Value.Valid { + t.Errorf("expected invalid, got %v", w2.Value) + } +} + +type xmlUUIDWrapper struct { + XMLName xml.Name `xml:"root"` + ID SqlUUID `xml:"id"` +} + +func TestSqlNull_XML_UUID(t *testing.T) { + id := uuid.New() + w := xmlUUIDWrapper{ID: NewSqlUUID(id)} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlUUIDWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if w2.ID.UUID() != id { + t.Errorf("expected %v, got %v", id, w2.ID.UUID()) + } +} + +type xmlByteWrapper struct { + XMLName xml.Name `xml:"root"` + Data SqlByteArray `xml:"data"` +} + +func TestSqlNull_XML_ByteArray_Base64(t *testing.T) { + orig := []byte{0xDE, 0xAD, 0xBE, 0xEF} + w := xmlByteWrapper{Data: NewSqlByteArray(orig)} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlByteWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if string(w2.Data.Val) != string(orig) { + t.Errorf("expected %v, got %v", orig, w2.Data.Val) + } +} + +// ── SqlTimeStamp / SqlDate / SqlTime: YAML + XML ──────────────────────────── + +func TestSqlTimeStamp_YAML(t *testing.T) { + ts := NewSqlTimeStamp(time.Date(2024, 6, 15, 9, 30, 0, 0, time.UTC)) + data, err := yaml.Marshal(ts) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "2024-06-15T09:30:00\n" { + t.Errorf("unexpected YAML: %q", string(data)) + } + var ts2 SqlTimeStamp + if err := yaml.Unmarshal(data, &ts2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if ts2.Time().Format("2006-01-02T15:04:05") != "2024-06-15T09:30:00" { + t.Errorf("expected 2024-06-15T09:30:00, got %v", ts2.Time()) + } + + var zero SqlTimeStamp + zdata, err := yaml.Marshal(zero) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(zdata) != "null\n" { + t.Errorf("expected null, got %q", zdata) + } +} + +type xmlTimeStampWrapper struct { + XMLName xml.Name `xml:"root"` + At SqlTimeStamp `xml:"at"` +} + +func TestSqlTimeStamp_XML(t *testing.T) { + w := xmlTimeStampWrapper{At: NewSqlTimeStamp(time.Date(2024, 6, 15, 9, 30, 0, 0, time.UTC))} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlTimeStampWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if w2.At.Time().Format("2006-01-02T15:04:05") != "2024-06-15T09:30:00" { + t.Errorf("expected 2024-06-15T09:30:00, got %v", w2.At.Time()) + } +} + +func TestSqlDate_YAML(t *testing.T) { + d := NewSqlDate(time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC)) + data, err := yaml.Marshal(d) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "\"2024-06-15\"\n" { + t.Errorf("unexpected YAML: %q", string(data)) + } + var d2 SqlDate + if err := yaml.Unmarshal(data, &d2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if d2.String() != "2024-06-15" { + t.Errorf("expected 2024-06-15, got %q", d2.String()) + } +} + +type xmlDateWrapper struct { + XMLName xml.Name `xml:"root"` + Day SqlDate `xml:"day"` +} + +func TestSqlDate_XML(t *testing.T) { + w := xmlDateWrapper{Day: NewSqlDate(time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC))} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlDateWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if w2.Day.String() != "2024-06-15" { + t.Errorf("expected 2024-06-15, got %q", w2.Day.String()) + } +} + +func TestSqlTime_YAML(t *testing.T) { + tm := NewSqlTime(time.Date(0, 1, 1, 14, 5, 9, 0, time.UTC)) + data, err := yaml.Marshal(tm) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "\"14:05:09\"\n" { + t.Errorf("unexpected YAML: %q", string(data)) + } + var tm2 SqlTime + if err := yaml.Unmarshal(data, &tm2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if tm2.String() != "14:05:09" { + t.Errorf("expected 14:05:09, got %q", tm2.String()) + } +} + +type xmlTimeWrapper struct { + XMLName xml.Name `xml:"root"` + At SqlTime `xml:"at"` +} + +func TestSqlTime_XML(t *testing.T) { + w := xmlTimeWrapper{At: NewSqlTime(time.Date(0, 1, 1, 14, 5, 9, 0, time.UTC))} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlTimeWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if w2.At.String() != "14:05:09" { + t.Errorf("expected 14:05:09, got %q", w2.At.String()) + } +} + +// ── SqlJSONB: YAML + XML ───────────────────────────────────────────────────── + +func TestSqlJSONB_YAML(t *testing.T) { + j := SqlJSONB(`{"name":"test","count":42}`) + data, err := yaml.Marshal(j) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var decoded map[string]any + if err := yaml.Unmarshal(data, &decoded); err != nil { + t.Fatalf("failed decoding produced YAML: %v", err) + } + if decoded["name"] != "test" { + t.Errorf("expected name=test, got %v", decoded["name"]) + } + + var j2 SqlJSONB + if err := yaml.Unmarshal(data, &j2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + m, err := j2.AsMap() + if err != nil { + t.Fatalf("AsMap failed: %v", err) + } + if m["name"] != "test" { + t.Errorf("expected name=test, got %v", m["name"]) + } +} + +func TestSqlJSONB_YAML_Null(t *testing.T) { + var j SqlJSONB + data, err := yaml.Marshal(j) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "null\n" { + t.Errorf("expected null, got %q", data) + } + var j2 SqlJSONB + if err := yaml.Unmarshal([]byte("null\n"), &j2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if j2 != nil { + t.Errorf("expected nil, got %v", j2) + } +} + +type xmlJSONBWrapper struct { + XMLName xml.Name `xml:"root"` + Meta SqlJSONB `xml:"meta"` +} + +func TestSqlJSONB_XML(t *testing.T) { + w := xmlJSONBWrapper{Meta: SqlJSONB(`{"key":"value"}`)} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlJSONBWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + m, err := w2.Meta.AsMap() + if err != nil { + t.Fatalf("AsMap failed: %v", err) + } + if m["key"] != "value" { + t.Errorf("expected key=value, got %v", m) + } +} + +// ── Array types: YAML ──────────────────────────────────────────────────────── + +func TestSqlStringArray_YAML(t *testing.T) { + a := NewSqlStringArray([]string{"a", "b", "c"}) + data, err := yaml.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var a2 SqlStringArray + if err := yaml.Unmarshal(data, &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if len(a2.Val) != 3 || a2.Val[1] != "b" { + t.Errorf("unexpected value %v", a2.Val) + } +} + +func TestSqlStringArray_YAML_Null(t *testing.T) { + var a SqlStringArray + data, err := yaml.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "null\n" { + t.Errorf("expected null, got %q", data) + } + var a2 SqlStringArray + if err := yaml.Unmarshal([]byte("null\n"), &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if a2.Valid { + t.Error("expected invalid") + } +} + +func TestSqlInt32Array_YAML(t *testing.T) { + a := NewSqlInt32Array([]int32{1, 2, 3}) + data, err := yaml.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var a2 SqlInt32Array + if err := yaml.Unmarshal(data, &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, v := range a.Val { + if a2.Val[i] != v { + t.Errorf("index %d: expected %d, got %d", i, v, a2.Val[i]) + } + } +} + +func TestSqlUUIDArray_YAML(t *testing.T) { + ids := []uuid.UUID{uuid.New(), uuid.New()} + a := NewSqlUUIDArray(ids) + data, err := yaml.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var a2 SqlUUIDArray + if err := yaml.Unmarshal(data, &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, v := range ids { + if a2.Val[i] != v { + t.Errorf("index %d: expected %v, got %v", i, v, a2.Val[i]) + } + } +} + +func TestSqlVector_YAML(t *testing.T) { + v := NewSqlVector([]float32{0.1, 0.2, 0.3}) + data, err := yaml.Marshal(v) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var v2 SqlVector + if err := yaml.Unmarshal(data, &v2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, f := range v.Val { + if v2.Val[i] != f { + t.Errorf("index %d: expected %v, got %v", i, f, v2.Val[i]) + } + } +} + +// ── Array types: XML ───────────────────────────────────────────────────────── + +type xmlStringArrayWrapper struct { + XMLName xml.Name `xml:"root"` + Tags SqlStringArray `xml:"tags"` +} + +func TestSqlStringArray_XML(t *testing.T) { + w := xmlStringArrayWrapper{Tags: NewSqlStringArray([]string{"x", "y", "z"})} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlStringArrayWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + want := []string{"x", "y", "z"} + if len(w2.Tags.Val) != len(want) { + t.Fatalf("expected %v, got %v", want, w2.Tags.Val) + } + for i := range want { + if w2.Tags.Val[i] != want[i] { + t.Errorf("index %d: expected %q, got %q", i, want[i], w2.Tags.Val[i]) + } + } +} + +func TestSqlStringArray_XML_Empty(t *testing.T) { + w := xmlStringArrayWrapper{} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlStringArrayWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if len(w2.Tags.Val) != 0 { + t.Errorf("expected empty slice, got %v", w2.Tags.Val) + } +} + +type xmlInt64ArrayWrapper struct { + XMLName xml.Name `xml:"root"` + Scores SqlInt64Array `xml:"scores"` +} + +func TestSqlInt64Array_XML(t *testing.T) { + w := xmlInt64ArrayWrapper{Scores: NewSqlInt64Array([]int64{10, 20, 30})} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlInt64ArrayWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + want := []int64{10, 20, 30} + for i := range want { + if w2.Scores.Val[i] != want[i] { + t.Errorf("index %d: expected %d, got %d", i, want[i], w2.Scores.Val[i]) + } + } +} + +type xmlUUIDArrayWrapper struct { + XMLName xml.Name `xml:"root"` + IDs SqlUUIDArray `xml:"ids"` +} + +func TestSqlUUIDArray_XML(t *testing.T) { + ids := []uuid.UUID{uuid.New(), uuid.New()} + w := xmlUUIDArrayWrapper{IDs: NewSqlUUIDArray(ids)} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlUUIDArrayWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, id := range ids { + if w2.IDs.Val[i] != id { + t.Errorf("index %d: expected %v, got %v", i, id, w2.IDs.Val[i]) + } + } +} + +type xmlVectorWrapper struct { + XMLName xml.Name `xml:"root"` + Embedding SqlVector `xml:"embedding"` +} + +func TestSqlVector_XML(t *testing.T) { + w := xmlVectorWrapper{Embedding: NewSqlVector([]float32{1.5, -2.5})} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlVectorWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + want := []float32{1.5, -2.5} + for i := range want { + if w2.Embedding.Val[i] != want[i] { + t.Errorf("index %d: expected %v, got %v", i, want[i], w2.Embedding.Val[i]) + } + } +} + +// ── Combined struct round-trip ─────────────────────────────────────────────── + +type yamlXMLRecord struct { + XMLName xml.Name `xml:"record" yaml:"-" json:"-"` + ID SqlUUID `xml:"id" yaml:"id"` + Name SqlString `xml:"name" yaml:"name"` + Age SqlInt32 `xml:"age" yaml:"age"` + Active SqlBool `xml:"active" yaml:"active"` + Created SqlTimeStamp `xml:"created" yaml:"created"` + Tags SqlStringArray `xml:"tags" yaml:"tags"` +} + +func TestCombinedRecord_YAML_RoundTrip(t *testing.T) { + id := uuid.New() + original := yamlXMLRecord{ + ID: NewSqlUUID(id), + Name: NewSqlString("Ada"), + Age: NewSqlInt32(36), + Active: NewSqlBool(true), + Created: NewSqlTimeStamp(time.Date(2024, 3, 10, 12, 30, 0, 0, time.UTC)), + Tags: NewSqlStringArray([]string{"engineer", "mathematician"}), + } + + data, err := yaml.Marshal(original) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var decoded yamlXMLRecord + if err := yaml.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if decoded.ID.UUID() != id { + t.Errorf("ID: expected %v, got %v", id, decoded.ID.UUID()) + } + if decoded.Name.String() != "Ada" { + t.Errorf("Name: expected Ada, got %q", decoded.Name.String()) + } + if decoded.Age.Int64() != 36 { + t.Errorf("Age: expected 36, got %d", decoded.Age.Int64()) + } + if !decoded.Active.Bool() { + t.Error("Active: expected true") + } + if decoded.Created.Time().Format("2006-01-02T15:04:05") != "2024-03-10T12:30:00" { + t.Errorf("Created: expected 2024-03-10T12:30:00, got %v", decoded.Created.Time()) + } + if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[0] != "engineer" { + t.Errorf("Tags: unexpected value %v", decoded.Tags.Val) + } +} + +func TestCombinedRecord_XML_RoundTrip(t *testing.T) { + id := uuid.New() + original := yamlXMLRecord{ + ID: NewSqlUUID(id), + Name: NewSqlString("Ada"), + Age: NewSqlInt32(36), + Active: NewSqlBool(true), + Created: NewSqlTimeStamp(time.Date(2024, 3, 10, 12, 30, 0, 0, time.UTC)), + Tags: NewSqlStringArray([]string{"engineer", "mathematician"}), + } + + data, err := xml.Marshal(original) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var decoded yamlXMLRecord + if err := xml.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if decoded.ID.UUID() != id { + t.Errorf("ID: expected %v, got %v", id, decoded.ID.UUID()) + } + if decoded.Name.String() != "Ada" { + t.Errorf("Name: expected Ada, got %q", decoded.Name.String()) + } + if decoded.Age.Int64() != 36 { + t.Errorf("Age: expected 36, got %d", decoded.Age.Int64()) + } + if !decoded.Active.Bool() { + t.Error("Active: expected true") + } + if decoded.Created.Time().Format("2006-01-02T15:04:05") != "2024-03-10T12:30:00" { + t.Errorf("Created: expected 2024-03-10T12:30:00, got %v", decoded.Created.Time()) + } + if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[0] != "engineer" { + t.Errorf("Tags: unexpected value %v", decoded.Tags.Val) + } +} diff --git a/struct_json_test.go b/struct_json_test.go new file mode 100644 index 0000000..f273bf1 --- /dev/null +++ b/struct_json_test.go @@ -0,0 +1,164 @@ +package sqltypes + +import ( + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" +) + +// record mimics a typical DB model composed of sqltypes fields, exercising +// marshalling/unmarshalling of the whole set together as encoding/json would +// when used on a real struct (not just the individual types in isolation). +type record struct { + ID SqlUUID `json:"id"` + Name SqlString `json:"name"` + Bio SqlString `json:"bio"` + Age SqlInt32 `json:"age"` + Score SqlFloat64 `json:"score"` + Active SqlBool `json:"active"` + CreatedAt SqlTimeStamp `json:"created_at"` + BirthDate SqlDate `json:"birth_date"` + Avatar SqlByteArray `json:"avatar"` + Tags SqlStringArray `json:"tags"` + Scores SqlInt32Array `json:"scores"` + Metadata SqlJSONB `json:"metadata"` + Embedding SqlVector `json:"embedding"` + Extras []uuid.UUID `json:"-"` +} + +func TestStruct_JSON_RoundTrip_AllFieldsPresent(t *testing.T) { + id := uuid.New() + createdAt := time.Date(2024, 3, 10, 12, 30, 0, 0, time.UTC) + birthDate := time.Date(1990, 5, 20, 0, 0, 0, 0, time.UTC) + + original := record{ + ID: NewSqlUUID(id), + Name: NewSqlString("Ada Lovelace"), + Bio: SqlString{}, // intentionally null + Age: NewSqlInt32(36), + Score: NewSqlFloat64(98.6), + Active: NewSqlBool(true), + CreatedAt: NewSqlTimeStamp(createdAt), + BirthDate: NewSqlDate(birthDate), + Avatar: NewSqlByteArray([]byte{0xDE, 0xAD, 0xBE, 0xEF}), + Tags: NewSqlStringArray([]string{"engineer", "mathematician"}), + Scores: NewSqlInt32Array([]int32{10, 20, 30}), + Metadata: SqlJSONB(`{"role":"admin"}`), + Embedding: NewSqlVector([]float32{0.1, 0.2, 0.3}), + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var decoded record + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if decoded.ID.UUID() != id { + t.Errorf("ID: expected %v, got %v", id, decoded.ID.UUID()) + } + if decoded.Name.String() != "Ada Lovelace" { + t.Errorf("Name: expected Ada Lovelace, got %q", decoded.Name.String()) + } + if decoded.Bio.Valid { + t.Errorf("Bio: expected invalid/null, got %v", decoded.Bio) + } + if decoded.Age.Int64() != 36 { + t.Errorf("Age: expected 36, got %d", decoded.Age.Int64()) + } + if decoded.Score.Float64() != 98.6 { + t.Errorf("Score: expected 98.6, got %v", decoded.Score.Float64()) + } + if !decoded.Active.Bool() { + t.Errorf("Active: expected true") + } + if decoded.CreatedAt.Time().Format("2006-01-02T15:04:05") != createdAt.Format("2006-01-02T15:04:05") { + t.Errorf("CreatedAt: expected %v, got %v", createdAt, decoded.CreatedAt.Time()) + } + if decoded.BirthDate.String() != "1990-05-20" { + t.Errorf("BirthDate: expected 1990-05-20, got %q", decoded.BirthDate.String()) + } + if string(decoded.Avatar.Val) != string(original.Avatar.Val) { + t.Errorf("Avatar: expected %v, got %v", original.Avatar.Val, decoded.Avatar.Val) + } + if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[0] != "engineer" { + t.Errorf("Tags: unexpected value %v", decoded.Tags.Val) + } + if len(decoded.Scores.Val) != 3 || decoded.Scores.Val[2] != 30 { + t.Errorf("Scores: unexpected value %v", decoded.Scores.Val) + } + m, err := decoded.Metadata.AsMap() + if err != nil || m["role"] != "admin" { + t.Errorf("Metadata: expected role=admin, got %v (err=%v)", m, err) + } + if len(decoded.Embedding.Val) != 3 || decoded.Embedding.Val[1] != 0.2 { + t.Errorf("Embedding: unexpected value %v", decoded.Embedding.Val) + } +} + +func TestStruct_JSON_RoundTrip_AllNull(t *testing.T) { + var original record + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var decoded record + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if decoded.ID.Valid || decoded.Name.Valid || decoded.Age.Valid || decoded.Score.Valid || + decoded.Active.Valid || decoded.CreatedAt.Valid || decoded.BirthDate.Valid || + decoded.Avatar.Valid || decoded.Tags.Valid || decoded.Scores.Valid || decoded.Embedding.Valid { + t.Errorf("expected all fields invalid/null after round-trip, got %+v", decoded) + } + if decoded.Metadata != nil { + t.Errorf("expected nil Metadata, got %v", decoded.Metadata) + } +} + +func TestStruct_JSON_UnmarshalFromRawJSON(t *testing.T) { + raw := `{ + "id": "3e843d4e-6b3c-4f2e-9a1e-6f0b2f3c9d10", + "name": "Grace Hopper", + "bio": null, + "age": 85, + "score": 100, + "active": false, + "created_at": "2023-12-01T08:00:00", + "birth_date": "1906-12-09", + "avatar": "AQIDBA==", + "tags": ["navy", "compiler"], + "scores": [1, 2, 3], + "metadata": {"key": "value"}, + "embedding": [1.0, 2.0] + }` + + var decoded record + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if decoded.Name.String() != "Grace Hopper" { + t.Errorf("expected Grace Hopper, got %q", decoded.Name.String()) + } + if decoded.Age.Int64() != 85 { + t.Errorf("expected age 85, got %d", decoded.Age.Int64()) + } + if decoded.Active.Valid && decoded.Active.Bool() { + t.Errorf("expected active=false") + } + if string(decoded.Avatar.Val) != string([]byte{1, 2, 3, 4}) { + t.Errorf("expected avatar bytes [1 2 3 4], got %v", decoded.Avatar.Val) + } + if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[1] != "compiler" { + t.Errorf("unexpected tags %v", decoded.Tags.Val) + } +} diff --git a/uuid_integration_test.go b/uuid_integration_test.go new file mode 100644 index 0000000..476d1d5 --- /dev/null +++ b/uuid_integration_test.go @@ -0,0 +1,180 @@ +package sqltypes + +import ( + "database/sql" + "testing" + + "github.com/google/uuid" + _ "modernc.org/sqlite" +) + +// TestUUIDWithRealDatabase tests that SqlUUID works with actual database operations +func TestUUIDWithRealDatabase(t *testing.T) { + // Open an in-memory SQLite database + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Create a test table with UUID column + _, err = db.Exec(` + CREATE TABLE test_users ( + id INTEGER PRIMARY KEY, + user_id TEXT, + name TEXT + ) + `) + if err != nil { + t.Fatalf("Failed to create table: %v", err) + } + + // Test 1: Insert with UUID + testUUID1 := uuid.New() + sqlUUID1 := NewSqlUUID(testUUID1) + + _, err = db.Exec("INSERT INTO test_users (id, user_id, name) VALUES (?, ?, ?)", + 1, sqlUUID1, "Alice") + if err != nil { + t.Fatalf("Failed to insert record: %v", err) + } + + // Test 2: Update with UUID + testUUID2 := uuid.New() + sqlUUID2 := NewSqlUUID(testUUID2) + + _, err = db.Exec("UPDATE test_users SET user_id = ? WHERE id = ?", + sqlUUID2, 1) + if err != nil { + t.Fatalf("Failed to update record: %v", err) + } + + // Test 3: Read back and verify + var retrievedID string + var name string + err = db.QueryRow("SELECT user_id, name FROM test_users WHERE id = ?", 1).Scan(&retrievedID, &name) + if err != nil { + t.Fatalf("Failed to query record: %v", err) + } + + if retrievedID != testUUID2.String() { + t.Errorf("Expected UUID %s, got %s", testUUID2.String(), retrievedID) + } + + if name != "Alice" { + t.Errorf("Expected name 'Alice', got '%s'", name) + } + + // Test 4: Insert with NULL UUID + nullUUID := SqlUUID{Valid: false} + _, err = db.Exec("INSERT INTO test_users (id, user_id, name) VALUES (?, ?, ?)", + 2, nullUUID, "Bob") + if err != nil { + t.Fatalf("Failed to insert record with NULL UUID: %v", err) + } + + // Test 5: Read NULL UUID back + var retrievedNullID sql.NullString + err = db.QueryRow("SELECT user_id FROM test_users WHERE id = ?", 2).Scan(&retrievedNullID) + if err != nil { + t.Fatalf("Failed to query NULL UUID record: %v", err) + } + + if retrievedNullID.Valid { + t.Errorf("Expected NULL UUID, got %s", retrievedNullID.String) + } + + t.Logf("All database operations with UUID succeeded!") +} + +// TestUUIDValueReturnsString verifies that Value() returns string, not uuid.UUID +func TestUUIDValueReturnsString(t *testing.T) { + testUUID := uuid.New() + sqlUUID := NewSqlUUID(testUUID) + + val, err := sqlUUID.Value() + if err != nil { + t.Fatalf("Value() failed: %v", err) + } + + // The value should be a string, not a uuid.UUID + strVal, ok := val.(string) + if !ok { + t.Fatalf("Expected Value() to return string, got %T", val) + } + + if strVal != testUUID.String() { + t.Errorf("Expected %s, got %s", testUUID.String(), strVal) + } + + t.Logf("✓ Value() correctly returns string: %s", strVal) +} + +// CustomStringableType is a custom type that implements fmt.Stringer +type CustomStringableType string + +func (c CustomStringableType) String() string { + return "custom:" + string(c) +} + +// TestCustomStringableType verifies that any type implementing fmt.Stringer works +func TestCustomStringableType(t *testing.T) { + customVal := CustomStringableType("test-value") + sqlCustom := SqlNull[CustomStringableType]{ + Val: customVal, + Valid: true, + } + + val, err := sqlCustom.Value() + if err != nil { + t.Fatalf("Value() failed: %v", err) + } + + // Should return the result of String() method + strVal, ok := val.(string) + if !ok { + t.Fatalf("Expected Value() to return string, got %T", val) + } + + expected := "custom:test-value" + if strVal != expected { + t.Errorf("Expected %s, got %s", expected, strVal) + } + + t.Logf("✓ Custom Stringer type correctly converted to string: %s", strVal) +} + +// TestStringMethodUsesStringer verifies that String() method also uses fmt.Stringer +func TestStringMethodUsesStringer(t *testing.T) { + // Test with UUID + testUUID := uuid.New() + sqlUUID := NewSqlUUID(testUUID) + + strResult := sqlUUID.String() + if strResult != testUUID.String() { + t.Errorf("Expected UUID String() to return %s, got %s", testUUID.String(), strResult) + } + t.Logf("✓ UUID String() method: %s", strResult) + + // Test with custom Stringer type + customVal := CustomStringableType("test-value") + sqlCustom := SqlNull[CustomStringableType]{ + Val: customVal, + Valid: true, + } + + customStr := sqlCustom.String() + expected := "custom:test-value" + if customStr != expected { + t.Errorf("Expected custom String() to return %s, got %s", expected, customStr) + } + t.Logf("✓ Custom Stringer String() method: %s", customStr) + + // Test with regular type (should use fmt.Sprintf) + sqlInt := NewSqlInt64(42) + intStr := sqlInt.String() + if intStr != "42" { + t.Errorf("Expected int String() to return '42', got '%s'", intStr) + } + t.Logf("✓ Regular type String() method: %s", intStr) +}