test(uuid): add integration tests for SqlUUID with database

* Implement tests for inserting, updating, and retrieving UUIDs in a SQLite database.
* Verify that Value() returns a string representation of the UUID.
* Ensure compatibility with custom types implementing fmt.Stringer.
* Update type mapper imports to reflect new package path.
This commit is contained in:
Hein
2026-07-02 17:06:41 +02:00
parent ee94ddc133
commit 99d63aa5f4
7 changed files with 2557 additions and 6 deletions
+755
View File
@@ -0,0 +1,755 @@
package spectypes
import (
"database/sql/driver"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/google/uuid"
)
// 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 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 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 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 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 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 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 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 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 NewSqlVector(val []float32) SqlVector {
return SqlVector{Val: val, Valid: true}
}
+658
View File
@@ -0,0 +1,658 @@
// Package spectypes provides nullable SQL types with automatic casting and conversion methods.
package spectypes
import (
"database/sql"
"database/sql/driver"
"encoding/base64"
"encoding/json"
"fmt"
"reflect"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
// 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, uint, uint8, uint16, uint32, uint64:
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
reflect.ValueOf(&n.Val).Elem().SetInt(i)
n.Valid = true
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
reflect.ValueOf(&n.Val).Elem().SetInt(int64(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)
}
// 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]
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 []byte(fmt.Sprintf(`"%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 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 []byte(fmt.Sprintf(`"%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 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 []byte(fmt.Sprintf(`"%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 SqlTimeNow() SqlTime {
return SqlTime{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
}
// SqlJSONB - Nullable JSONB as []byte.
type SqlJSONB []byte
// 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
}
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 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}}
}
+958
View File
@@ -0,0 +1,958 @@
package spectypes
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)
}
}
+180
View File
@@ -0,0 +1,180 @@
package spectypes
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)
}
+2 -2
View File
@@ -461,9 +461,9 @@ func (tm *TypeMapper) NeedsFmtImport(generateGetIDStr bool) bool {
return generateGetIDStr
}
// GetSQLTypesImport returns the import path for the ResolveSpec spectypes package.
// GetSQLTypesImport returns the import path for the spectypes package.
func (tm *TypeMapper) GetSQLTypesImport() string {
return "github.com/bitechdev/ResolveSpec/pkg/spectypes"
return "git.warky.dev/wdevs/relspecgo/pkg/spectypes"
}
// GetNullableTypeImportLine returns the full Go import line for the nullable type
+2 -2
View File
@@ -505,9 +505,9 @@ func (tm *TypeMapper) NeedsFmtImport(generateGetIDStr bool) bool {
return generateGetIDStr
}
// GetSQLTypesImport returns the import path for the ResolveSpec spectypes package.
// GetSQLTypesImport returns the import path for the spectypes package.
func (tm *TypeMapper) GetSQLTypesImport() string {
return "github.com/bitechdev/ResolveSpec/pkg/spectypes"
return "git.warky.dev/wdevs/relspecgo/pkg/spectypes"
}
// GetNullableTypeImportLine returns the full Go import line for the nullable type
+2 -2
View File
@@ -23,7 +23,7 @@ type Writer interface {
// NullableType constants control which Go package is used for nullable column types
// in code-generation writers (Bun, GORM).
const (
// NullableTypeResolveSpec uses github.com/bitechdev/ResolveSpec/pkg/spectypes
// NullableTypeResolveSpec uses git.warky.dev/wdevs/relspecgo/pkg/spectypes
// (SqlString, SqlInt32, SqlVector, SqlStringArray, …).
NullableTypeResolveSpec = "resolvespec"
@@ -52,7 +52,7 @@ type WriterOptions struct {
// NullableTypes selects the Go type package used for nullable columns in
// code-generation writers (bun, gorm). Accepted values:
// "resolvespec" (default) — github.com/bitechdev/ResolveSpec/pkg/spectypes
// "resolvespec" (default) — git.warky.dev/wdevs/relspecgo/pkg/spectypes
// "stdlib" — database/sql (sql.NullString, sql.NullInt32, …)
// "baselib" — plain Go pointer types (*string, *int32, …)
NullableTypes string