refactor: ♻️ change resolvspec types to build in sqltypes

This commit is contained in:
Hein
2026-07-07 15:28:01 +02:00
parent 99d63aa5f4
commit 40a0e6a0aa
26 changed files with 2362 additions and 121 deletions
+13
View File
@@ -151,8 +151,21 @@ pkg/merge/ Schema merging
pkg/models/ Internal data models
pkg/transform/ Transformation logic
pkg/pgsql/ PostgreSQL utilities
pkg/sqltypes/ Nullable SQL types for generated/hand-written models (see below)
```
## Nullable Types (`pkg/sqltypes`)
The `bun` and `gorm` writers can generate model structs using
[`pkg/sqltypes`](./pkg/sqltypes/README.md) — nullable types (`SqlString`,
`SqlInt32`, `SqlTimeStamp`, `SqlStringArray`, …) that implement
`database/sql.Scanner`, `driver.Valuer`, and JSON/YAML/XML marshalling in one
type, selected via `--types sqltypes`. See the
[`pkg/sqltypes` README](./pkg/sqltypes/README.md) for the full type
reference, or the [`bun`](./pkg/writers/bun/README.md) /
[`gorm`](./pkg/writers/gorm/README.md) writer docs for the `--types` flag
(`sqltypes`, `stdlib`, or `baselib`).
## Contributing
1. Register or sign in with GitHub at [git.warky.dev](https://git.warky.dev)
+1 -1
View File
@@ -177,7 +177,7 @@ func init() {
convertCmd.Flags().StringVar(&convertPackageName, "package", "", "Package name (for code generation formats like gorm/bun)")
convertCmd.Flags().StringVar(&convertSchemaFilter, "schema", "", "Filter to a specific schema by name (required for formats like dctx that only support single schemas)")
convertCmd.Flags().BoolVar(&convertFlattenSchema, "flatten-schema", false, "Flatten schema.table names to schema_table (useful for databases like SQLite that do not support schemas)")
convertCmd.Flags().StringVar(&convertNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'baselib' (default, Go pointer types), 'stdlib' (database/sql), or 'resolvespec'")
convertCmd.Flags().StringVar(&convertNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'baselib' (default, Go pointer types), 'stdlib' (database/sql), or 'sqltypes'")
convertCmd.Flags().BoolVar(&convertContinueOnError, "continue-on-error", false, "Prepend \\set ON_ERROR_STOP off to generated SQL so psql continues past errors (pgsql output only)")
err := convertCmd.MarkFlagRequired("from")
+1 -1
View File
@@ -111,7 +111,7 @@ func init() {
splitCmd.Flags().StringVar(&splitTables, "tables", "", "Comma-separated list of table names to include (case-insensitive)")
splitCmd.Flags().StringVar(&splitExcludeSchema, "exclude-schema", "", "Comma-separated list of schema names to exclude")
splitCmd.Flags().StringVar(&splitExcludeTables, "exclude-tables", "", "Comma-separated list of table names to exclude (case-insensitive)")
splitCmd.Flags().StringVar(&splitNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'baselib' (default, Go pointer types), 'stdlib' (database/sql), or 'resolvespec'")
splitCmd.Flags().StringVar(&splitNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'baselib' (default, Go pointer types), 'stdlib' (database/sql), or 'sqltypes'")
err := splitCmd.MarkFlagRequired("from")
if err != nil {
+11 -11
View File
@@ -3,17 +3,17 @@ package models_bun
// //ModelCoreMasterprocess - Generated Table for Schema core
// type ModelCoreMasterprocess struct {
// bun.BaseModel `bun:"table:core.masterprocess,alias:masterprocess"`
// Description resolvespec_common.SqlString `json:"description" bun:"description,type:citext,"`
// GUID resolvespec_common.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Inactive resolvespec_common.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
// Jsonvalue resolvespec_common.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
// Ridjsonschema resolvespec_common.SqlInt32 `json:"rid_jsonschema" bun:"rid_jsonschema,type:integer,"`
// Ridmasterprocess resolvespec_common.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,pk,default:nextval('core.identity_masterprocess_rid_masterprocess'::regclass),"`
// Ridmastertypehubtype resolvespec_common.SqlInt32 `json:"rid_mastertype_hubtype" bun:"rid_mastertype_hubtype,type:integer,"`
// Ridmastertypeprocesstype resolvespec_common.SqlInt32 `json:"rid_mastertype_processtype" bun:"rid_mastertype_processtype,type:integer,"`
// Ridprogrammodule resolvespec_common.SqlInt32 `json:"rid_programmodule" bun:"rid_programmodule,type:integer,"`
// Sequenceno resolvespec_common.SqlInt32 `json:"sequenceno" bun:"sequenceno,type:integer,"`
// Singleprocess resolvespec_common.SqlInt16 `json:"singleprocess" bun:"singleprocess,type:smallint,"`
// Description sql_types.SqlString `json:"description" bun:"description,type:citext,"`
// GUID sql_types.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Inactive sql_types.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
// Jsonvalue sql_types.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
// Ridjsonschema sql_types.SqlInt32 `json:"rid_jsonschema" bun:"rid_jsonschema,type:integer,"`
// Ridmasterprocess sql_types.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,pk,default:nextval('core.identity_masterprocess_rid_masterprocess'::regclass),"`
// Ridmastertypehubtype sql_types.SqlInt32 `json:"rid_mastertype_hubtype" bun:"rid_mastertype_hubtype,type:integer,"`
// Ridmastertypeprocesstype sql_types.SqlInt32 `json:"rid_mastertype_processtype" bun:"rid_mastertype_processtype,type:integer,"`
// Ridprogrammodule sql_types.SqlInt32 `json:"rid_programmodule" bun:"rid_programmodule,type:integer,"`
// Sequenceno sql_types.SqlInt32 `json:"sequenceno" bun:"sequenceno,type:integer,"`
// Singleprocess sql_types.SqlInt16 `json:"singleprocess" bun:"singleprocess,type:smallint,"`
// Updatecnt int64 `json:"updatecnt" bun:"updatecnt,type:integer,default:0,"`
// JSON *ModelCoreJsonschema `json:"JSON,omitempty" bun:"rel:has-one,join:rid_jsonschema=rid_jsonschema"`
// MTT_RID_MASTERTYPE_HUBTYPE *ModelCoreMastertype `json:"MTT_RID_MASTERTYPE_HUBTYPE,omitempty" bun:"rel:has-one,join:rid_mastertype_hubtype=rid_mastertype"`
+20 -20
View File
@@ -3,26 +3,26 @@ package models_bun
// //ModelCoreMastertask - Generated Table for Schema core
// type ModelCoreMastertask struct {
// bun.BaseModel `bun:"table:core.mastertask,alias:mastertask"`
// Allactionsmustcomplete resolvespec_common.SqlInt16 `json:"allactionsmustcomplete" bun:"allactionsmustcomplete,type:smallint,"`
// Condition resolvespec_common.SqlString `json:"condition" bun:"condition,type:citext,"`
// Description resolvespec_common.SqlString `json:"description" bun:"description,type:citext,"`
// Dueday resolvespec_common.SqlInt16 `json:"dueday" bun:"dueday,type:smallint,"`
// Dueoption resolvespec_common.SqlString `json:"dueoption" bun:"dueoption,type:citext,"`
// Escalation resolvespec_common.SqlInt32 `json:"escalation" bun:"escalation,type:integer,"`
// Escalationoption resolvespec_common.SqlString `json:"escalationoption" bun:"escalationoption,type:citext,"`
// GUID resolvespec_common.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Inactive resolvespec_common.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
// Jsonvalue resolvespec_common.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
// Mastertasknote resolvespec_common.SqlString `json:"mastertasknote" bun:"mastertasknote,type:citext,"`
// Repeatinterval resolvespec_common.SqlInt16 `json:"repeatinterval" bun:"repeatinterval,type:smallint,"`
// Repeattype resolvespec_common.SqlString `json:"repeattype" bun:"repeattype,type:citext,"`
// Ridjsonschema resolvespec_common.SqlInt32 `json:"rid_jsonschema" bun:"rid_jsonschema,type:integer,"`
// Ridmasterprocess resolvespec_common.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,"`
// Ridmastertask resolvespec_common.SqlInt32 `json:"rid_mastertask" bun:"rid_mastertask,type:integer,pk,default:nextval('core.identity_mastertask_rid_mastertask'::regclass),"`
// Ridmastertypetasktype resolvespec_common.SqlInt32 `json:"rid_mastertype_tasktype" bun:"rid_mastertype_tasktype,type:integer,"`
// Sequenceno resolvespec_common.SqlInt32 `json:"sequenceno" bun:"sequenceno,type:integer,"`
// Singletask resolvespec_common.SqlInt16 `json:"singletask" bun:"singletask,type:smallint,"`
// Startday resolvespec_common.SqlInt16 `json:"startday" bun:"startday,type:smallint,"`
// Allactionsmustcomplete sql_types.SqlInt16 `json:"allactionsmustcomplete" bun:"allactionsmustcomplete,type:smallint,"`
// Condition sql_types.SqlString `json:"condition" bun:"condition,type:citext,"`
// Description sql_types.SqlString `json:"description" bun:"description,type:citext,"`
// Dueday sql_types.SqlInt16 `json:"dueday" bun:"dueday,type:smallint,"`
// Dueoption sql_types.SqlString `json:"dueoption" bun:"dueoption,type:citext,"`
// Escalation sql_types.SqlInt32 `json:"escalation" bun:"escalation,type:integer,"`
// Escalationoption sql_types.SqlString `json:"escalationoption" bun:"escalationoption,type:citext,"`
// GUID sql_types.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Inactive sql_types.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
// Jsonvalue sql_types.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
// Mastertasknote sql_types.SqlString `json:"mastertasknote" bun:"mastertasknote,type:citext,"`
// Repeatinterval sql_types.SqlInt16 `json:"repeatinterval" bun:"repeatinterval,type:smallint,"`
// Repeattype sql_types.SqlString `json:"repeattype" bun:"repeattype,type:citext,"`
// Ridjsonschema sql_types.SqlInt32 `json:"rid_jsonschema" bun:"rid_jsonschema,type:integer,"`
// Ridmasterprocess sql_types.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,"`
// Ridmastertask sql_types.SqlInt32 `json:"rid_mastertask" bun:"rid_mastertask,type:integer,pk,default:nextval('core.identity_mastertask_rid_mastertask'::regclass),"`
// Ridmastertypetasktype sql_types.SqlInt32 `json:"rid_mastertype_tasktype" bun:"rid_mastertype_tasktype,type:integer,"`
// Sequenceno sql_types.SqlInt32 `json:"sequenceno" bun:"sequenceno,type:integer,"`
// Singletask sql_types.SqlInt16 `json:"singletask" bun:"singletask,type:smallint,"`
// Startday sql_types.SqlInt16 `json:"startday" bun:"startday,type:smallint,"`
// Updatecnt int64 `json:"updatecnt" bun:"updatecnt,type:integer,default:0,"`
// JSON *ModelCoreJsonschema `json:"JSON,omitempty" bun:"rel:has-one,join:rid_jsonschema=rid_jsonschema"`
// MPR *ModelCoreMasterprocess `json:"MPR,omitempty" bun:"rel:has-one,join:rid_masterprocess=rid_masterprocess"`
+12 -12
View File
@@ -3,18 +3,18 @@ package models_bun
// //ModelCoreMastertype - Generated Table for Schema core
// type ModelCoreMastertype struct {
// bun.BaseModel `bun:"table:core.mastertype,alias:mastertype"`
// Category resolvespec_common.SqlString `json:"category" bun:"category,type:citext,"`
// Description resolvespec_common.SqlString `json:"description" bun:"description,type:citext,"`
// Disableedit resolvespec_common.SqlInt16 `json:"disableedit" bun:"disableedit,type:smallint,"`
// Forprefix resolvespec_common.SqlString `json:"forprefix" bun:"forprefix,type:citext,"`
// GUID resolvespec_common.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Hidden resolvespec_common.SqlInt16 `json:"hidden" bun:"hidden,type:smallint,"`
// Inactive resolvespec_common.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
// Jsonvalue resolvespec_common.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
// Mastertype resolvespec_common.SqlString `json:"mastertype" bun:"mastertype,type:citext,"`
// Note resolvespec_common.SqlString `json:"note" bun:"note,type:citext,"`
// Ridmastertype resolvespec_common.SqlInt32 `json:"rid_mastertype" bun:"rid_mastertype,type:integer,pk,default:nextval('core.identity_mastertype_rid_mastertype'::regclass),"`
// Ridparent resolvespec_common.SqlInt32 `json:"rid_parent" bun:"rid_parent,type:integer,"`
// Category sql_types.SqlString `json:"category" bun:"category,type:citext,"`
// Description sql_types.SqlString `json:"description" bun:"description,type:citext,"`
// Disableedit sql_types.SqlInt16 `json:"disableedit" bun:"disableedit,type:smallint,"`
// Forprefix sql_types.SqlString `json:"forprefix" bun:"forprefix,type:citext,"`
// GUID sql_types.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Hidden sql_types.SqlInt16 `json:"hidden" bun:"hidden,type:smallint,"`
// Inactive sql_types.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
// Jsonvalue sql_types.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
// Mastertype sql_types.SqlString `json:"mastertype" bun:"mastertype,type:citext,"`
// Note sql_types.SqlString `json:"note" bun:"note,type:citext,"`
// Ridmastertype sql_types.SqlInt32 `json:"rid_mastertype" bun:"rid_mastertype,type:integer,pk,default:nextval('core.identity_mastertype_rid_mastertype'::regclass),"`
// Ridparent sql_types.SqlInt32 `json:"rid_parent" bun:"rid_parent,type:integer,"`
// Updatecnt int64 `json:"updatecnt" bun:"updatecnt,type:integer,default:0,"`
// MTT *ModelCoreMastertype `json:"MTT,omitempty" bun:"rel:has-one,join:rid_mastertype=rid_parent"`
+8 -8
View File
@@ -3,15 +3,15 @@ package models_bun
// //ModelCoreProcess - Generated Table for Schema core
// type ModelCoreProcess struct {
// bun.BaseModel `bun:"table:core.process,alias:process"`
// Completedate resolvespec_common.SqlDate `json:"completedate" bun:"completedate,type:date,"`
// Completedate sql_types.SqlDate `json:"completedate" bun:"completedate,type:date,"`
// Completetime types.CustomIntTime `json:"completetime" bun:"completetime,type:integer,"`
// Description resolvespec_common.SqlString `json:"description" bun:"description,type:citext,"`
// GUID resolvespec_common.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Ridcompleteuser resolvespec_common.SqlInt32 `json:"rid_completeuser" bun:"rid_completeuser,type:integer,"`
// Ridhub resolvespec_common.SqlInt32 `json:"rid_hub" bun:"rid_hub,type:integer,"`
// Ridmasterprocess resolvespec_common.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,"`
// Ridprocess resolvespec_common.SqlInt32 `json:"rid_process" bun:"rid_process,type:integer,pk,default:nextval('core.identity_process_rid_process'::regclass),"`
// Status resolvespec_common.SqlString `json:"status" bun:"status,type:citext,"`
// Description sql_types.SqlString `json:"description" bun:"description,type:citext,"`
// GUID sql_types.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Ridcompleteuser sql_types.SqlInt32 `json:"rid_completeuser" bun:"rid_completeuser,type:integer,"`
// Ridhub sql_types.SqlInt32 `json:"rid_hub" bun:"rid_hub,type:integer,"`
// Ridmasterprocess sql_types.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,"`
// Ridprocess sql_types.SqlInt32 `json:"rid_process" bun:"rid_process,type:integer,pk,default:nextval('core.identity_process_rid_process'::regclass),"`
// Status sql_types.SqlString `json:"status" bun:"status,type:citext,"`
// Updatecnt int64 `json:"updatecnt" bun:"updatecnt,type:integer,default:0,"`
// HUB *ModelCoreHub `json:"HUB,omitempty" bun:"rel:has-one,join:rid_hub=rid_hub"`
// MPR *ModelCoreMasterprocess `json:"MPR,omitempty" bun:"rel:has-one,join:rid_masterprocess=rid_masterprocess"`
+2 -2
View File
@@ -746,7 +746,7 @@ func (r *Reader) goTypeToSQL(expr ast.Expr) string {
if t.Sel.Name == "Time" {
return "timestamp"
}
case "resolvespec_common", "sql_types":
case "sql_types":
return r.sqlTypeToSQL(t.Sel.Name)
}
}
@@ -787,7 +787,7 @@ func (r *Reader) isNullableGoType(expr ast.Expr) bool {
case *ast.SelectorExpr:
// Check for sql_types nullable types
if ident, ok := t.X.(*ast.Ident); ok {
if ident.Name == "resolvespec_common" || ident.Name == "sql_types" {
if ident.Name == "sql_types" {
return strings.HasPrefix(t.Sel.Name, "Sql")
}
}
+136
View File
@@ -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.
@@ -1,15 +1,85 @@
package spectypes
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 <item> 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 <item> 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) {
@@ -140,6 +210,32 @@ func (a *SqlStringArray) UnmarshalJSON(b []byte) error {
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}
}
@@ -215,6 +311,32 @@ func (a *SqlInt16Array) UnmarshalJSON(b []byte) error {
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}
}
@@ -290,6 +412,32 @@ func (a *SqlInt32Array) UnmarshalJSON(b []byte) error {
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}
}
@@ -365,6 +513,32 @@ func (a *SqlInt64Array) UnmarshalJSON(b []byte) error {
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}
}
@@ -440,6 +614,32 @@ func (a *SqlFloat32Array) UnmarshalJSON(b []byte) error {
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}
}
@@ -515,6 +715,32 @@ func (a *SqlFloat64Array) UnmarshalJSON(b []byte) error {
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}
}
@@ -591,6 +817,32 @@ func (a *SqlBoolArray) UnmarshalJSON(b []byte) error {
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}
}
@@ -666,6 +918,32 @@ func (a *SqlUUIDArray) UnmarshalJSON(b []byte) error {
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}
}
@@ -750,6 +1028,32 @@ func (v *SqlVector) UnmarshalJSON(b []byte) error {
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}
}
+485
View File
@@ -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")
}
})
}
@@ -1,11 +1,12 @@
// Package spectypes provides nullable SQL types with automatic casting and conversion methods.
package spectypes
// 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"
@@ -13,6 +14,7 @@ import (
"time"
"github.com/google/uuid"
"gopkg.in/yaml.v3"
)
// tryParseDT attempts to parse a string into a time.Time using various formats.
@@ -120,15 +122,22 @@ func (n *SqlNull[T]) FromString(s string) error {
var zero T
switch any(zero).(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
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
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
} 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)
@@ -232,6 +241,104 @@ func (n *SqlNull[T]) UnmarshalJSON(b []byte) error {
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 {
@@ -329,6 +436,7 @@ type (
SqlInt16 = SqlNull[int16]
SqlInt32 = SqlNull[int32]
SqlInt64 = SqlNull[int64]
SqlFloat32 = SqlNull[float32]
SqlFloat64 = SqlNull[float64]
SqlBool = SqlNull[bool]
SqlString = SqlNull[string]
@@ -343,7 +451,7 @@ 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
return fmt.Appendf(nil, `"%s"`, t.Val.Format("2006-01-02T15:04:05")), nil
}
func (t *SqlTimeStamp) UnmarshalJSON(b []byte) error {
@@ -363,6 +471,49 @@ func (t SqlTimeStamp) Value() (driver.Value, error) {
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}}
}
@@ -378,7 +529,7 @@ func (d SqlDate) MarshalJSON() ([]byte, error) {
if strings.HasPrefix(s, "0001-01-01") {
return []byte("null"), nil
}
return []byte(fmt.Sprintf(`"%s"`, s)), nil
return fmt.Appendf(nil, `"%s"`, s), nil
}
func (d *SqlDate) UnmarshalJSON(b []byte) error {
@@ -413,6 +564,57 @@ func (d SqlDate) String() string {
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}}
}
@@ -428,7 +630,7 @@ func (t SqlTime) MarshalJSON() ([]byte, error) {
if s == "00:00:00" {
return []byte("null"), nil
}
return []byte(fmt.Sprintf(`"%s"`, s)), nil
return fmt.Appendf(nil, `"%s"`, s), nil
}
func (t *SqlTime) UnmarshalJSON(b []byte) error {
@@ -455,6 +657,57 @@ func (t SqlTime) String() string {
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}}
}
@@ -462,6 +715,11 @@ func SqlTimeNow() SqlTime {
// 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 {
@@ -518,6 +776,67 @@ func (n *SqlJSONB) UnmarshalJSON(b []byte) error {
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
@@ -625,6 +944,10 @@ 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}
}
+134
View File
@@ -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)
}
})
}
@@ -1,4 +1,4 @@
package spectypes
package sqltypes
import (
"database/sql/driver"
+678
View File
@@ -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)
}
}
+164
View File
@@ -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)
}
}
@@ -1,4 +1,4 @@
package spectypes
package sqltypes
import (
"database/sql"
+17 -15
View File
@@ -6,6 +6,8 @@ Generates Go source files with Bun model definitions from database schema inform
The Bun Writer converts RelSpec's internal database model representation into Go source code with Bun struct definitions, complete with proper tags, relationships, and table configuration.
With `--types sqltypes`, nullable fields use the [`pkg/sqltypes`](../../sqltypes/README.md) package.
## Features
- Generates Bun-compatible Go structs
@@ -46,19 +48,19 @@ func main() {
### CLI Examples
```bash
# Generate Bun models from a DBML schema (default: resolvespec types)
# Generate Bun models from a DBML schema (default: baselib pointer types)
relspec convert --from dbml --from-path schema.dbml \
--to bun --to-path models.go --package models
# Use standard library database/sql nullable types instead of resolvespec
# Use standard library database/sql nullable types instead
relspec convert --from dbml --from-path schema.dbml \
--to bun --to-path models.go --package models \
--types stdlib
# Explicitly select resolvespec types (same as omitting --types)
# Select sqltypes package types (git.warky.dev/wdevs/relspecgo/pkg/sqltypes)
relspec convert --from pgsql --from-conn "postgres://localhost/mydb" \
--to bun --to-path models.go --package models \
--types resolvespec
--types sqltypes
# Multi-file output (one file per table)
relspec convert --from json --from-path schema.json \
@@ -67,13 +69,13 @@ relspec convert --from json --from-path schema.json \
## Generated Code Examples
### Default — resolvespec types (`--types resolvespec`)
### sqltypes package types (`--types sqltypes`)
```go
package models
import (
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
@@ -82,9 +84,9 @@ type User struct {
ID int64 `bun:"id,type:uuid,pk," json:"id"`
Username string `bun:"username,type:text,notnull," json:"username"`
Email resolvespec_common.SqlString `bun:"email,type:text,nullzero," json:"email"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Email sql_types.SqlString `bun:"email,type:text,nullzero," json:"email"`
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
}
```
@@ -126,7 +128,7 @@ type User struct {
The nullable type package is selected with `--types` (or `WriterOptions.NullableTypes`).
| SQL Type | NOT NULL (both) | Nullable — resolvespec | Nullable — stdlib |
| SQL Type | NOT NULL (both) | Nullable — sqltypes | Nullable — stdlib |
|---|---|---|---|
| `bigint` | `int64` | `SqlInt64` | `sql.NullInt64` |
| `integer` | `int32` | `SqlInt32` | `sql.NullInt32` |
@@ -142,7 +144,7 @@ The nullable type package is selected with `--types` (or `WriterOptions.Nullable
| `uuid[]` | `SqlUUIDArray` | `SqlUUIDArray` | `[]string` |
| `vector` | `SqlVector` | `SqlVector` | `[]float32` |
\* In resolvespec mode, NOT NULL timestamps use `SqlTimeStamp` (not `time.Time`) unless the base type is a simple integer or boolean. In stdlib mode, NOT NULL timestamps use `time.Time`.
\* In sqltypes mode, NOT NULL timestamps use `SqlTimeStamp` (not `time.Time`) unless the base type is a simple integer or boolean. In stdlib mode, NOT NULL timestamps use `time.Time`.
## Writer Options
@@ -151,11 +153,11 @@ The nullable type package is selected with `--types` (or `WriterOptions.Nullable
Controls which Go package is used for nullable column types. Set via the `--types` CLI flag or `WriterOptions.NullableTypes`:
```go
// Use resolvespec types (default — omit NullableTypes or set to "resolvespec")
// Use sqltypes package types
options := &writers.WriterOptions{
OutputPath: "models.go",
PackageName: "models",
NullableTypes: writers.NullableTypeResolveSpec,
NullableTypes: writers.NullableTypeSqlTypes,
}
// Use standard library database/sql types
@@ -184,8 +186,8 @@ options := &writers.WriterOptions{
- Model names are derived from table names (singularized, PascalCase)
- Table aliases are auto-generated from table names
- Nullable columns use `resolvespec_common.SqlString`, `resolvespec_common.SqlTimeStamp`, etc. by default; pass `--types stdlib` to use `sql.NullString`, `sql.NullTime`, etc. instead
- Array columns use `resolvespec_common.SqlStringArray`, `resolvespec_common.SqlInt32Array`, etc. by default; `--types stdlib` produces plain Go slices (`[]string`, `[]int32`, …)
- Nullable columns use plain Go pointer types (`*string`, `*time.Time`, …) by default; pass `--types sqltypes` to use `sql_types.SqlString`, `sql_types.SqlTimeStamp`, etc., or `--types stdlib` to use `sql.NullString`, `sql.NullTime`, etc.
- Array columns use plain Go slices (`[]string`, `[]int32`, …) by default; `--types sqltypes` uses `sql_types.SqlStringArray`, `sql_types.SqlInt32Array`, etc.
- Multi-file mode: one file per table named `sql_{schema}_{table}.go`
- Generated code is auto-formatted
- JSON tags are automatically added
+3 -3
View File
@@ -141,10 +141,10 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper, fl
safeName := writers.SanitizeStructTagValue(col.Name)
model.PrimaryKeyField = SnakeCaseToPascalCase(safeName)
model.IDColumnName = safeName
// Check if PK type is a SQL type (contains resolvespec_common or sql_types)
// Check if PK type is a SQL type (contains sql_types)
goType := typeMapper.SQLTypeToGoType(col.Type, col.NotNull)
model.PrimaryKeyType = goType
model.PrimaryKeyIsSQL = strings.Contains(goType, "resolvespec_common") || strings.Contains(goType, "sql_types")
model.PrimaryKeyIsSQL = strings.Contains(goType, "sql_types")
model.PrimaryKeyIsStr = isStringLikePrimaryKeyType(goType)
model.PrimaryKeyIDType = "int64"
if model.PrimaryKeyIsStr {
@@ -203,7 +203,7 @@ func formatComment(description, comment string) string {
func isStringLikePrimaryKeyType(goType string) bool {
switch goType {
case "string", "sql.NullString", "resolvespec_common.SqlString", "resolvespec_common.SqlUUID":
case "string", "sql.NullString", "sql_types.SqlString", "sql_types.SqlUUID":
return true
default:
return false
+9 -9
View File
@@ -12,18 +12,18 @@ import (
// TypeMapper handles type conversions between SQL and Go types for Bun
type TypeMapper struct {
sqlTypesAlias string
typeStyle string // writers.NullableTypeResolveSpec | writers.NullableTypeStdlib
typeStyle string // writers.NullableTypeSqlTypes | writers.NullableTypeStdlib | writers.NullableTypeBaselib
}
// NewTypeMapper creates a new TypeMapper.
// typeStyle should be writers.NullableTypeResolveSpec or writers.NullableTypeStdlib;
// an empty string defaults to resolvespec.
// typeStyle should be writers.NullableTypeSqlTypes, writers.NullableTypeStdlib, or
// writers.NullableTypeBaselib; an empty string defaults to baselib.
func NewTypeMapper(typeStyle string) *TypeMapper {
if typeStyle == "" {
typeStyle = writers.NullableTypeBaselib
}
return &TypeMapper{
sqlTypesAlias: "resolvespec_common",
sqlTypesAlias: "sql_types",
typeStyle: typeStyle,
}
}
@@ -50,7 +50,7 @@ func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string {
return tm.baselibNullableGoType(baseType)
}
// resolvespec (default): use base Go types only for simple NOT NULL fields.
// sqltypes: use base Go types only for simple NOT NULL fields.
if notNull && tm.isSimpleType(baseType) {
return tm.baseGoType(baseType)
}
@@ -106,11 +106,11 @@ func (tm *TypeMapper) baseGoType(sqlType string) string {
return goType
}
// Default to resolvespec type
// Default to sqltypes type
return tm.bunGoType(sqlType)
}
// bunGoType returns the Bun/ResolveSpec common type
// bunGoType returns the Bun/sqltypes common type
func (tm *TypeMapper) bunGoType(sqlType string) string {
typeMap := map[string]string{
// Integer types
@@ -461,9 +461,9 @@ func (tm *TypeMapper) NeedsFmtImport(generateGetIDStr bool) bool {
return generateGetIDStr
}
// GetSQLTypesImport returns the import path for the spectypes package.
// GetSQLTypesImport returns the import path for the sqltypes package.
func (tm *TypeMapper) GetSQLTypesImport() string {
return "git.warky.dev/wdevs/relspecgo/pkg/spectypes"
return "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
}
// GetNullableTypeImportLine returns the full Go import line for the nullable type
+2 -2
View File
@@ -80,7 +80,7 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
// Add bun import (always needed)
templateData.AddImport(fmt.Sprintf("\"%s\"", w.typeMapper.GetBunImport()))
// Add nullable types import (resolvespec or stdlib depending on options)
// Add nullable types import (sqltypes or stdlib depending on options)
templateData.AddImport(w.typeMapper.GetNullableTypeImportLine())
// Collect all models
@@ -177,7 +177,7 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
// Add bun import
templateData.AddImport(fmt.Sprintf("\"%s\"", w.typeMapper.GetBunImport()))
// Add nullable types import (resolvespec or stdlib depending on options)
// Add nullable types import (sqltypes or stdlib depending on options)
templateData.AddImport(w.typeMapper.GetNullableTypeImportLine())
// Create model data
+3 -3
View File
@@ -550,7 +550,7 @@ func TestWriter_FieldNameCollision(t *testing.T) {
}
// Verify NO field named just "TableName" (without underscore)
if strings.Contains(generated, "TableName resolvespec_common") || strings.Contains(generated, "TableName string") {
if strings.Contains(generated, "TableName sql_types") || strings.Contains(generated, "TableName string") {
t.Errorf("Field 'TableName' without underscore should not exist (would conflict with method)\nGenerated:\n%s", generated)
}
}
@@ -827,9 +827,9 @@ func TestTypeMapper_BuildBunTag(t *testing.T) {
t.Errorf("BuildBunTag() = %q, missing %q", result, part)
}
}
// resolvespec mode must NOT add "array" — SqlXxxArray uses sql.Scanner
// sqltypes mode must NOT add "array" — SqlXxxArray uses sql.Scanner
if strings.Contains(result, ",array,") || strings.HasSuffix(result, ",array,") {
t.Errorf("BuildBunTag() = %q, must not contain 'array' in resolvespec mode", result)
t.Errorf("BuildBunTag() = %q, must not contain 'array' in sqltypes mode", result)
}
})
}
+11 -9
View File
@@ -6,6 +6,8 @@ Generates Go source files with GORM model definitions from database schema infor
The GORM Writer converts RelSpec's internal database model representation into Go source code with GORM struct definitions, complete with proper tags, relationships, and methods.
With `--types sqltypes`, nullable fields use the [`pkg/sqltypes`](../../sqltypes/README.md) package.
## Features
- Generates GORM-compatible Go structs
@@ -48,19 +50,19 @@ func main() {
### CLI Examples
```bash
# Generate GORM models from a DBML schema (default: resolvespec types)
# Generate GORM models from a DBML schema (default: baselib pointer types)
relspec convert --from dbml --from-path schema.dbml \
--to gorm --to-path models.go --package models
# Use standard library database/sql nullable types instead of resolvespec
# Use standard library database/sql nullable types instead
relspec convert --from dbml --from-path schema.dbml \
--to gorm --to-path models.go --package models \
--types stdlib
# Explicitly select resolvespec types (same as omitting --types)
# Select sqltypes package types (git.warky.dev/wdevs/relspecgo/pkg/sqltypes)
relspec convert --from pgsql --from-conn "postgres://localhost/mydb" \
--to gorm --to-path models.go --package models \
--types resolvespec
--types sqltypes
# Multi-file output (one file per table)
relspec convert --from json --from-path schema.json \
@@ -89,13 +91,13 @@ Files are named: `sql_{schema}_{table}.go`
## Generated Code Examples
### Default — resolvespec types (`--types resolvespec`)
### sqltypes package types (`--types sqltypes`)
```go
package models
import (
sql_types "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
)
type ModelUser struct {
@@ -141,11 +143,11 @@ func (ModelUser) TableName() string {
Controls which Go package is used for nullable column types. Set via the `--types` CLI flag or `WriterOptions.NullableTypes`:
```go
// Use resolvespec types (default — omit NullableTypes or set to "resolvespec")
// Use sqltypes package types
options := &writers.WriterOptions{
OutputPath: "models.go",
PackageName: "models",
NullableTypes: writers.NullableTypeResolveSpec,
NullableTypes: writers.NullableTypeSqlTypes,
}
// Use standard library database/sql types
@@ -176,7 +178,7 @@ options := &writers.WriterOptions{
The nullable type package is selected with `--types` (or `WriterOptions.NullableTypes`).
| SQL Type | NOT NULL — both | Nullable — resolvespec | Nullable — stdlib |
| SQL Type | NOT NULL — both | Nullable — sqltypes | Nullable — stdlib |
|---|---|---|---|
| `bigint` | `int64` | `SqlInt64` | `sql.NullInt64` |
| `integer` | `int32` | `SqlInt32` | `sql.NullInt32` |
+6 -6
View File
@@ -12,12 +12,12 @@ import (
// TypeMapper handles type conversions between SQL and Go types
type TypeMapper struct {
sqlTypesAlias string
typeStyle string // writers.NullableTypeResolveSpec | writers.NullableTypeStdlib
typeStyle string // writers.NullableTypeSqlTypes | writers.NullableTypeStdlib | writers.NullableTypeBaselib
}
// NewTypeMapper creates a new TypeMapper.
// typeStyle should be writers.NullableTypeResolveSpec or writers.NullableTypeStdlib;
// an empty string defaults to resolvespec.
// typeStyle should be writers.NullableTypeSqlTypes, writers.NullableTypeStdlib, or
// writers.NullableTypeBaselib; an empty string defaults to baselib.
func NewTypeMapper(typeStyle string) *TypeMapper {
if typeStyle == "" {
typeStyle = writers.NullableTypeBaselib
@@ -50,7 +50,7 @@ func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string {
return tm.baselibNullableGoType(baseType)
}
// resolvespec (default)
// sqltypes
if notNull {
return tm.baseGoType(baseType)
}
@@ -505,9 +505,9 @@ func (tm *TypeMapper) NeedsFmtImport(generateGetIDStr bool) bool {
return generateGetIDStr
}
// GetSQLTypesImport returns the import path for the spectypes package.
// GetSQLTypesImport returns the import path for the sqltypes package.
func (tm *TypeMapper) GetSQLTypesImport() string {
return "git.warky.dev/wdevs/relspecgo/pkg/spectypes"
return "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
}
// GetNullableTypeImportLine returns the full Go import line for the nullable type
+2 -2
View File
@@ -77,7 +77,7 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
packageName := w.getPackageName()
templateData := NewTemplateData(packageName, w.config)
// Add nullable types import (resolvespec or stdlib depending on options)
// Add nullable types import (sqltypes or stdlib depending on options)
templateData.AddImport(w.typeMapper.GetNullableTypeImportLine())
// Collect all models
@@ -171,7 +171,7 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
// Create template data for this single table
templateData := NewTemplateData(packageName, w.config)
// Add nullable types import (resolvespec or stdlib depending on options)
// Add nullable types import (sqltypes or stdlib depending on options)
templateData.AddImport(w.typeMapper.GetNullableTypeImportLine())
// Create model data
+4 -4
View File
@@ -23,9 +23,9 @@ type Writer interface {
// NullableType constants control which Go package is used for nullable column types
// in code-generation writers (Bun, GORM).
const (
// NullableTypeResolveSpec uses git.warky.dev/wdevs/relspecgo/pkg/spectypes
// NullableTypeSqlTypes uses git.warky.dev/wdevs/relspecgo/pkg/sqltypes
// (SqlString, SqlInt32, SqlVector, SqlStringArray, …).
NullableTypeResolveSpec = "resolvespec"
NullableTypeSqlTypes = "sqltypes"
// NullableTypeStdlib uses the standard library database/sql nullable types
// (sql.NullString, sql.NullInt32, …) and plain Go slices for arrays.
@@ -52,9 +52,9 @@ type WriterOptions struct {
// NullableTypes selects the Go type package used for nullable columns in
// code-generation writers (bun, gorm). Accepted values:
// "resolvespec" (default) — git.warky.dev/wdevs/relspecgo/pkg/spectypes
// "sqltypes" — git.warky.dev/wdevs/relspecgo/pkg/sqltypes
// "stdlib" — database/sql (sql.NullString, sql.NullInt32, …)
// "baselib" — plain Go pointer types (*string, *int32, …)
// "baselib" (default) — plain Go pointer types (*string, *int32, …)
NullableTypes string
// Prisma7 enables Prisma 7-specific output for Prisma writers.