From 40a0e6a0aae92aa2a7967d1c1ef642f41a061a36 Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 7 Jul 2026 15:28:01 +0200 Subject: [PATCH] refactor: :recycle: change resolvspec types to build in sqltypes --- README.md | 13 + cmd/relspec/convert.go | 2 +- cmd/relspec/split.go | 2 +- examples/bun/sql_core_masterprocess.go | 22 +- examples/bun/sql_core_mastertask.go | 40 +- examples/bun/sql_core_mastertype.go | 24 +- examples/bun/sql_core_process.go | 16 +- pkg/readers/bun/reader.go | 4 +- pkg/sqltypes/README.md | 136 ++++ .../sql_array_types.go | 306 +++++++- pkg/sqltypes/sql_array_types_test.go | 485 +++++++++++++ pkg/{spectypes => sqltypes}/sql_types.go | 339 ++++++++- pkg/sqltypes/sql_types_fromstring_test.go | 134 ++++ pkg/{spectypes => sqltypes}/sql_types_test.go | 2 +- pkg/sqltypes/sql_types_yaml_xml_test.go | 678 ++++++++++++++++++ pkg/sqltypes/struct_json_test.go | 164 +++++ .../uuid_integration_test.go | 2 +- pkg/writers/bun/README.md | 36 +- pkg/writers/bun/template_data.go | 6 +- pkg/writers/bun/type_mapper.go | 18 +- pkg/writers/bun/writer.go | 4 +- pkg/writers/bun/writer_test.go | 6 +- pkg/writers/gorm/README.md | 20 +- pkg/writers/gorm/type_mapper.go | 12 +- pkg/writers/gorm/writer.go | 4 +- pkg/writers/writer.go | 8 +- 26 files changed, 2362 insertions(+), 121 deletions(-) create mode 100644 pkg/sqltypes/README.md rename pkg/{spectypes => sqltypes}/sql_array_types.go (68%) create mode 100644 pkg/sqltypes/sql_array_types_test.go rename pkg/{spectypes => sqltypes}/sql_types.go (63%) create mode 100644 pkg/sqltypes/sql_types_fromstring_test.go rename pkg/{spectypes => sqltypes}/sql_types_test.go (99%) create mode 100644 pkg/sqltypes/sql_types_yaml_xml_test.go create mode 100644 pkg/sqltypes/struct_json_test.go rename pkg/{spectypes => sqltypes}/uuid_integration_test.go (99%) diff --git a/README.md b/README.md index 0ea4f71..c6178e5 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/cmd/relspec/convert.go b/cmd/relspec/convert.go index 21541d3..6977f48 100644 --- a/cmd/relspec/convert.go +++ b/cmd/relspec/convert.go @@ -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") diff --git a/cmd/relspec/split.go b/cmd/relspec/split.go index acbe986..81fcc97 100644 --- a/cmd/relspec/split.go +++ b/cmd/relspec/split.go @@ -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 { diff --git a/examples/bun/sql_core_masterprocess.go b/examples/bun/sql_core_masterprocess.go index 27282b4..95853f6 100755 --- a/examples/bun/sql_core_masterprocess.go +++ b/examples/bun/sql_core_masterprocess.go @@ -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"` diff --git a/examples/bun/sql_core_mastertask.go b/examples/bun/sql_core_mastertask.go index ec37cff..0ee854c 100755 --- a/examples/bun/sql_core_mastertask.go +++ b/examples/bun/sql_core_mastertask.go @@ -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"` diff --git a/examples/bun/sql_core_mastertype.go b/examples/bun/sql_core_mastertype.go index 50b0ff1..46554ff 100755 --- a/examples/bun/sql_core_mastertype.go +++ b/examples/bun/sql_core_mastertype.go @@ -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"` diff --git a/examples/bun/sql_core_process.go b/examples/bun/sql_core_process.go index 17612fa..10b5108 100755 --- a/examples/bun/sql_core_process.go +++ b/examples/bun/sql_core_process.go @@ -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"` diff --git a/pkg/readers/bun/reader.go b/pkg/readers/bun/reader.go index efcb263..d09200e 100644 --- a/pkg/readers/bun/reader.go +++ b/pkg/readers/bun/reader.go @@ -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") } } diff --git a/pkg/sqltypes/README.md b/pkg/sqltypes/README.md new file mode 100644 index 0000000..2818d15 --- /dev/null +++ b/pkg/sqltypes/README.md @@ -0,0 +1,136 @@ +# sqltypes + +Nullable SQL types for hand-written or generated Go models. Each type wraps a +value with a `Valid` flag and implements `database/sql.Scanner`, +`driver.Valuer`, `encoding/json`, `gopkg.in/yaml.v3`, and `encoding/xml` +marshalling — so a single struct field can be scanned from a database row, +round-tripped through JSON/YAML/XML, and written back to the database without +any per-format glue code. + +This package is what the `bun` and `gorm` writers emit when generating models +with `--types sqltypes` (see [`pkg/writers/bun`](../writers/bun/README.md) and +[`pkg/writers/gorm`](../writers/gorm/README.md)). It can also be imported +directly in hand-written models. + +## Import + +```go +import sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes" +``` + +## Scalar types + +All scalar types are instantiations of the generic `SqlNull[T]`: + +| Type | Underlying | Typical SQL type | +|---|---|---| +| `SqlInt16` | `int16` | `smallint` | +| `SqlInt32` | `int32` | `integer` | +| `SqlInt64` | `int64` | `bigint` | +| `SqlFloat32` | `float32` | `real`, `float4` | +| `SqlFloat64` | `float64` | `double precision`, `numeric`, `decimal`, `money` | +| `SqlBool` | `bool` | `boolean` | +| `SqlString` | `string` | `text`, `varchar`, `char`, `citext`, `inet`, `cidr`, `macaddr` | +| `SqlByteArray` | `[]byte` | `bytea` (base64-encoded in JSON/YAML/XML) | +| `SqlUUID` | `uuid.UUID` (`github.com/google/uuid`) | `uuid` | + +You can also instantiate `SqlNull[T]` directly for any type not covered +above, e.g. `SqlNull[MyEnum]`. + +### Date/time types + +Plain `time.Time` doesn't distinguish date-only, time-only, and timestamp +semantics, and its zero value marshals to a confusing `0001-01-01T00:00:00Z`. +These wrapper types fix both problems: + +| Type | Format | Notes | +|---|---|---| +| `SqlTimeStamp` | `2006-01-02T15:04:05` | Full timestamp | +| `SqlDate` | `2006-01-02` | Date only | +| `SqlTime` | `15:04:05` | Time only | + +Zero/pre-epoch values (`time.Time{}` or anything before `0002-01-01`) marshal +to `null` and `Value()` returns `nil`, instead of leaking Go's zero-time +sentinel into the database or API responses. + +### JSON types + +| Type | Underlying | Notes | +|---|---|---| +| `SqlJSONB` | `[]byte` | Raw JSON bytes; `MarshalYAML` decodes to native YAML mappings/sequences instead of an embedded JSON string | +| `SqlJSON` | `= SqlJSONB` | Alias — PostgreSQL's `json` and `jsonb` share the same Go representation | + +`SqlJSONB` has `AsMap()` / `AsSlice()` helpers for pulling out +`map[string]any` / `[]any` without a separate `json.Unmarshal` call. + +### Vector type (pgvector) + +`SqlVector` wraps `[]float32` for the `vector` column type ([pgvector](https://github.com/pgvector/pgvector)), +scanning/writing the `[1,2,3]` literal format pgvector uses over the wire. + +## Array types + +PostgreSQL array columns (`text[]`, `integer[]`, …) map to `SqlXxxArray` +types, each wrapping `Val []T` + `Valid bool` and handling PostgreSQL's +`{a,b,c}` array literal format on `Scan`/`Value`: + +`SqlStringArray`, `SqlInt16Array`, `SqlInt32Array`, `SqlInt64Array`, +`SqlFloat32Array`, `SqlFloat64Array`, `SqlBoolArray`, `SqlUUIDArray`. + +## Constructing values + +Every type has a `NewSqlXxx(v)` constructor that sets `Valid: true`: + +```go +name := sql_types.NewSqlString("Ada Lovelace") +age := sql_types.NewSqlInt32(36) +tags := sql_types.NewSqlStringArray([]string{"engineer", "mathematician"}) +``` + +The zero value of any type (`sql_types.SqlString{}`) is null/invalid — use it +directly for a `NULL` field instead of a separate constructor. + +Generic helpers: + +```go +sql_types.Null(v, valid) // SqlNull[T]{Val: v, Valid: valid} +sql_types.NewSql[T](anyValue) // best-effort conversion from any Go value +``` + +## Reading values back + +Each scalar type has typed accessors that return the zero value instead of +panicking when `Valid` is false: + +```go +n.Int64() // SqlInt16/32/64, SqlFloat32/64, SqlBool, SqlString → int64 +n.Float64() // → float64 +n.Bool() // → bool +n.Time() // SqlNull[time.Time]-based types → time.Time +n.UUID() // SqlUUID → uuid.UUID +n.String() // fmt.Stringer — empty string when invalid +``` + +## Example + +```go +type User struct { + ID sql_types.SqlUUID `json:"id"` + Name sql_types.SqlString `json:"name"` + Tags sql_types.SqlStringArray `json:"tags"` + Metadata sql_types.SqlJSONB `json:"metadata"` + CreatedAt sql_types.SqlTimeStamp `json:"created_at"` +} + +u := User{ + ID: sql_types.NewSqlUUID(uuid.New()), + Name: sql_types.NewSqlString("Ada Lovelace"), + Tags: sql_types.NewSqlStringArray([]string{"engineer"}), + CreatedAt: sql_types.SqlTimeStampNow(), +} +// Metadata left as the zero value → serializes as null, scans as NULL. +``` + +Every type implements `sql.Scanner` and `driver.Valuer`, so these fields can +be used directly as struct fields with `database/sql`, `bun`, or `gorm` +without additional tags or hooks. diff --git a/pkg/spectypes/sql_array_types.go b/pkg/sqltypes/sql_array_types.go similarity index 68% rename from pkg/spectypes/sql_array_types.go rename to pkg/sqltypes/sql_array_types.go index 921387b..2b0816f 100644 --- a/pkg/spectypes/sql_array_types.go +++ b/pkg/sqltypes/sql_array_types.go @@ -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 child per element. +func marshalXMLSlice[T any](e *xml.Encoder, start xml.StartElement, valid bool, vals []T) error { + if !valid { + return e.EncodeElement("", start) + } + if err := e.EncodeToken(start); err != nil { + return err + } + for _, v := range vals { + if err := e.EncodeElement(v, xml.StartElement{Name: xmlArrayItemName}); err != nil { + return err + } + } + return e.EncodeToken(start.End()) +} + +// unmarshalXMLSlice reads back the XML written by marshalXMLSlice. +// +// XML has no native null representation: an element with no children +// unmarshals to a valid, empty slice rather than an invalid one. +func unmarshalXMLSlice[T any](d *xml.Decoder, start xml.StartElement) ([]T, error) { + var vals []T + for { + tok, err := d.Token() + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + var v T + if err := d.DecodeElement(&v, &t); err != nil { + return nil, err + } + vals = append(vals, v) + case xml.EndElement: + if t.Name == start.Name { + return vals, nil + } + } + } +} + // parsePostgresArrayElements parses a PostgreSQL array literal (e.g. `{a,"b,c",d}`) // into a slice of raw string elements. Each element retains its unquoted/unescaped value. func parsePostgresArrayElements(s string) ([]string, error) { @@ -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} } diff --git a/pkg/sqltypes/sql_array_types_test.go b/pkg/sqltypes/sql_array_types_test.go new file mode 100644 index 0000000..5691f24 --- /dev/null +++ b/pkg/sqltypes/sql_array_types_test.go @@ -0,0 +1,485 @@ +package sqltypes + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" +) + +func TestParsePostgresArrayElements(t *testing.T) { + tests := []struct { + name string + input string + want []string + wantErr bool + }{ + {"simple", "{a,b,c}", []string{"a", "b", "c"}, false}, + {"empty array", "{}", []string{}, false}, + {"null", "NULL", nil, false}, + {"lowercase null", "null", nil, false}, + {"empty string", "", nil, false}, + {"quoted with comma", `{a,"b,c",d}`, []string{"a", "b,c", "d"}, false}, + {"escaped quote", `{"a""b"}`, []string{`a"b`}, false}, + {"escaped backslash", `{"a\\b"}`, []string{`a\b`}, false}, + {"not an array", "abc", nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parsePostgresArrayElements(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(tt.want) { + t.Fatalf("expected %v, got %v", tt.want, got) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("index %d: expected %q, got %q", i, tt.want[i], got[i]) + } + } + }) + } +} + +func TestFormatPostgresStringArray(t *testing.T) { + tests := []struct { + name string + input []string + want string + }{ + {"nil", nil, "NULL"}, + {"empty", []string{}, "{}"}, + {"simple", []string{"a", "b"}, "{a,b}"}, + {"needs quoting comma", []string{"a,b"}, `{"a,b"}`}, + {"needs quoting empty elem", []string{""}, `{""}`}, + {"needs quoting quote", []string{`a"b`}, `{"a""b"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatPostgresStringArray(tt.input) + if got != tt.want { + t.Errorf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestSqlStringArray(t *testing.T) { + t.Run("scan and value round-trip", func(t *testing.T) { + var a SqlStringArray + if err := a.Scan(`{a,"b,c",d}`); err != nil { + t.Fatalf("Scan failed: %v", err) + } + want := []string{"a", "b,c", "d"} + if len(a.Val) != len(want) { + t.Fatalf("expected %v, got %v", want, a.Val) + } + for i := range want { + if a.Val[i] != want[i] { + t.Errorf("index %d: expected %q, got %q", i, want[i], a.Val[i]) + } + } + + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + var b SqlStringArray + if err := b.Scan(val); err != nil { + t.Fatalf("re-scan failed: %v", err) + } + for i := range want { + if b.Val[i] != want[i] { + t.Errorf("round-trip index %d: expected %q, got %q", i, want[i], b.Val[i]) + } + } + }) + + t.Run("scan nil", func(t *testing.T) { + var a SqlStringArray + if err := a.Scan(nil); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if a.Valid { + t.Error("expected invalid") + } + }) + + t.Run("value invalid", func(t *testing.T) { + a := SqlStringArray{Valid: false} + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + if val != nil { + t.Errorf("expected nil, got %v", val) + } + }) + + t.Run("scan wrong type", func(t *testing.T) { + var a SqlStringArray + if err := a.Scan(42); err == nil { + t.Error("expected error for unsupported scan type") + } + }) + + t.Run("json round-trip", func(t *testing.T) { + a := NewSqlStringArray([]string{"x", "y", "z"}) + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != `["x","y","z"]` { + t.Errorf("unexpected JSON: %s", data) + } + var a2 SqlStringArray + if err := json.Unmarshal(data, &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if !a2.Valid || len(a2.Val) != 3 { + t.Fatalf("expected 3 valid elements, got %v", a2) + } + }) + + t.Run("json null", func(t *testing.T) { + var a SqlStringArray + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "null" { + t.Errorf("expected null, got %s", data) + } + var a2 SqlStringArray + if err := json.Unmarshal([]byte("null"), &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if a2.Valid { + t.Error("expected invalid after unmarshaling null") + } + }) + + t.Run("json unmarshal invalid type errors", func(t *testing.T) { + var a SqlStringArray + if err := json.Unmarshal([]byte(`42`), &a); err == nil { + t.Error("expected error unmarshaling non-array JSON") + } + }) +} + +func TestSqlInt16Array(t *testing.T) { + t.Run("scan and value", func(t *testing.T) { + var a SqlInt16Array + if err := a.Scan("{1,2,-3}"); err != nil { + t.Fatalf("Scan failed: %v", err) + } + want := []int16{1, 2, -3} + for i := range want { + if a.Val[i] != want[i] { + t.Errorf("index %d: expected %d, got %d", i, want[i], a.Val[i]) + } + } + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + if val != "{1,2,-3}" { + t.Errorf("expected {1,2,-3}, got %v", val) + } + }) + + t.Run("scan invalid element", func(t *testing.T) { + var a SqlInt16Array + if err := a.Scan("{1,abc}"); err == nil { + t.Error("expected error for non-numeric element") + } + }) + + t.Run("json round-trip", func(t *testing.T) { + a := NewSqlInt16Array([]int16{5, 10, 15}) + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var a2 SqlInt16Array + if err := json.Unmarshal(data, &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, v := range a.Val { + if a2.Val[i] != v { + t.Errorf("index %d: expected %d, got %d", i, v, a2.Val[i]) + } + } + }) +} + +func TestSqlInt32Array(t *testing.T) { + a := NewSqlInt32Array([]int32{100000, -200000}) + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + var b SqlInt32Array + if err := b.Scan(val); err != nil { + t.Fatalf("Scan failed: %v", err) + } + for i, v := range a.Val { + if b.Val[i] != v { + t.Errorf("index %d: expected %d, got %d", i, v, b.Val[i]) + } + } + + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var c SqlInt32Array + if err := json.Unmarshal(data, &c); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, v := range a.Val { + if c.Val[i] != v { + t.Errorf("index %d: expected %d, got %d", i, v, c.Val[i]) + } + } +} + +func TestSqlInt64Array(t *testing.T) { + a := NewSqlInt64Array([]int64{9223372036854775807, -9223372036854775808}) + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + var b SqlInt64Array + if err := b.Scan(val); err != nil { + t.Fatalf("Scan failed: %v", err) + } + for i, v := range a.Val { + if b.Val[i] != v { + t.Errorf("index %d: expected %d, got %d", i, v, b.Val[i]) + } + } + + t.Run("null json", func(t *testing.T) { + var n SqlInt64Array + data, _ := json.Marshal(n) + if string(data) != "null" { + t.Errorf("expected null, got %s", data) + } + }) +} + +func TestSqlFloat32Array(t *testing.T) { + a := NewSqlFloat32Array([]float32{1.5, -2.25, 0}) + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + var b SqlFloat32Array + if err := b.Scan(val); err != nil { + t.Fatalf("Scan failed: %v", err) + } + for i, v := range a.Val { + if b.Val[i] != v { + t.Errorf("index %d: expected %v, got %v", i, v, b.Val[i]) + } + } + + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var c SqlFloat32Array + if err := json.Unmarshal(data, &c); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, v := range a.Val { + if c.Val[i] != v { + t.Errorf("index %d: expected %v, got %v", i, v, c.Val[i]) + } + } +} + +func TestSqlFloat64Array(t *testing.T) { + a := NewSqlFloat64Array([]float64{3.14159, -2.71828}) + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + var b SqlFloat64Array + if err := b.Scan(val); err != nil { + t.Fatalf("Scan failed: %v", err) + } + for i, v := range a.Val { + if b.Val[i] != v { + t.Errorf("index %d: expected %v, got %v", i, v, b.Val[i]) + } + } +} + +func TestSqlBoolArray(t *testing.T) { + t.Run("scan various truthy forms", func(t *testing.T) { + var a SqlBoolArray + if err := a.Scan("{t,f,true,false,1,0,yes}"); err != nil { + t.Fatalf("Scan failed: %v", err) + } + want := []bool{true, false, true, false, true, false, true} + for i := range want { + if a.Val[i] != want[i] { + t.Errorf("index %d: expected %v, got %v", i, want[i], a.Val[i]) + } + } + }) + + t.Run("value formatting", func(t *testing.T) { + a := NewSqlBoolArray([]bool{true, false}) + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + if val != "{t,f}" { + t.Errorf("expected {t,f}, got %v", val) + } + }) + + t.Run("json round-trip", func(t *testing.T) { + a := NewSqlBoolArray([]bool{true, false, true}) + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var a2 SqlBoolArray + if err := json.Unmarshal(data, &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, v := range a.Val { + if a2.Val[i] != v { + t.Errorf("index %d: expected %v, got %v", i, v, a2.Val[i]) + } + } + }) +} + +func TestSqlUUIDArray(t *testing.T) { + u1, u2 := uuid.New(), uuid.New() + + t.Run("scan and value round-trip", func(t *testing.T) { + a := NewSqlUUIDArray([]uuid.UUID{u1, u2}) + val, err := a.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + var b SqlUUIDArray + if err := b.Scan(val); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if b.Val[0] != u1 || b.Val[1] != u2 { + t.Errorf("expected [%v %v], got %v", u1, u2, b.Val) + } + }) + + t.Run("scan invalid uuid element", func(t *testing.T) { + var a SqlUUIDArray + if err := a.Scan("{not-a-uuid}"); err == nil { + t.Error("expected error for invalid uuid element") + } + }) + + t.Run("json round-trip", func(t *testing.T) { + a := NewSqlUUIDArray([]uuid.UUID{u1, u2}) + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var a2 SqlUUIDArray + if err := json.Unmarshal(data, &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if a2.Val[0] != u1 || a2.Val[1] != u2 { + t.Errorf("expected [%v %v], got %v", u1, u2, a2.Val) + } + }) +} + +func TestSqlVector(t *testing.T) { + t.Run("scan and value round-trip", func(t *testing.T) { + var v SqlVector + if err := v.Scan("[1,2.5,-3]"); err != nil { + t.Fatalf("Scan failed: %v", err) + } + want := []float32{1, 2.5, -3} + for i := range want { + if v.Val[i] != want[i] { + t.Errorf("index %d: expected %v, got %v", i, want[i], v.Val[i]) + } + } + val, err := v.Value() + if err != nil { + t.Fatalf("Value failed: %v", err) + } + if val != "[1,2.5,-3]" { + t.Errorf("expected [1,2.5,-3], got %v", val) + } + }) + + t.Run("scan empty vector", func(t *testing.T) { + var v SqlVector + if err := v.Scan("[]"); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if !v.Valid || len(v.Val) != 0 { + t.Errorf("expected valid empty vector, got %v", v) + } + }) + + t.Run("scan invalid literal", func(t *testing.T) { + var v SqlVector + if err := v.Scan("not-a-vector"); err == nil { + t.Error("expected error for invalid vector literal") + } + }) + + t.Run("json round-trip", func(t *testing.T) { + v := NewSqlVector([]float32{0.1, 0.2, 0.3}) + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var v2 SqlVector + if err := json.Unmarshal(data, &v2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, f := range v.Val { + if v2.Val[i] != f { + t.Errorf("index %d: expected %v, got %v", i, f, v2.Val[i]) + } + } + }) + + t.Run("json null", func(t *testing.T) { + var v SqlVector + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "null" { + t.Errorf("expected null, got %s", data) + } + var v2 SqlVector + if err := json.Unmarshal([]byte("null"), &v2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if v2.Valid { + t.Error("expected invalid after unmarshaling null") + } + }) +} diff --git a/pkg/spectypes/sql_types.go b/pkg/sqltypes/sql_types.go similarity index 63% rename from pkg/spectypes/sql_types.go rename to pkg/sqltypes/sql_types.go index f9a81d6..0b3146c 100644 --- a/pkg/spectypes/sql_types.go +++ b/pkg/sqltypes/sql_types.go @@ -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} } diff --git a/pkg/sqltypes/sql_types_fromstring_test.go b/pkg/sqltypes/sql_types_fromstring_test.go new file mode 100644 index 0000000..d8c536f --- /dev/null +++ b/pkg/sqltypes/sql_types_fromstring_test.go @@ -0,0 +1,134 @@ +package sqltypes + +import ( + "encoding/json" + "testing" +) + +// TestSqlNull_FromString_UnsignedInt guards against a regression where +// FromString called reflect.Value.SetInt on unsigned-kind fields, which +// panics since SetInt only accepts Int/Int8/.../Int64 kinds. +func TestSqlNull_FromString_UnsignedInt(t *testing.T) { + tests := []struct { + name string + input string + expected uint32 + }{ + {"simple", "123", 123}, + {"zero", "0", 0}, + {"large", "4000000000", 4000000000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var n SqlNull[uint32] + if err := n.FromString(tt.input); err != nil { + t.Fatalf("FromString failed: %v", err) + } + if !n.Valid { + t.Fatalf("expected valid=true") + } + if n.Val != tt.expected { + t.Errorf("expected %d, got %d", tt.expected, n.Val) + } + }) + } +} + +// TestSqlNull_FromString_Int64Precision guards against a regression where +// FromString unconditionally re-parsed the string as float64 after a +// successful ParseInt, corrupting large int64 values due to float rounding +// (e.g. math.MaxInt64 became negative). +func TestSqlNull_FromString_Int64Precision(t *testing.T) { + tests := []struct { + name string + input string + expected int64 + }{ + {"max int64", "9223372036854775807", 9223372036854775807}, + {"min int64", "-9223372036854775808", -9223372036854775808}, + {"large safe value", "1234567890123456", 1234567890123456}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var n SqlNull[int64] + if err := n.FromString(tt.input); err != nil { + t.Fatalf("FromString failed: %v", err) + } + if !n.Valid { + t.Fatalf("expected valid=true") + } + if n.Val != tt.expected { + t.Errorf("expected %d, got %d", tt.expected, n.Val) + } + }) + } +} + +// TestSqlNull_FromString_FloatFallback verifies that non-integral strings +// still parse via the float fallback path for integer-kind fields. +func TestSqlNull_FromString_FloatFallback(t *testing.T) { + var n SqlNull[int32] + if err := n.FromString("42.9"); err != nil { + t.Fatalf("FromString failed: %v", err) + } + if !n.Valid || n.Val != 42 { + t.Errorf("expected valid int32=42, got valid=%v val=%d", n.Valid, n.Val) + } + + var u SqlNull[uint32] + if err := u.FromString("42.9"); err != nil { + t.Fatalf("FromString failed: %v", err) + } + if !u.Valid || u.Val != 42 { + t.Errorf("expected valid uint32=42, got valid=%v val=%d", u.Valid, u.Val) + } + + var neg SqlNull[uint32] + if err := neg.FromString("-1.5"); err != nil { + t.Fatalf("FromString failed: %v", err) + } + if neg.Valid { + t.Errorf("expected invalid for negative value into unsigned type, got %v", neg.Val) + } +} + +// TestSqlNull_UnsignedTypes_ScanAndJSON exercises Scan and JSON round-trip +// for every unsigned alias to make sure none of them panic. +func TestSqlNull_UnsignedTypes_ScanAndJSON(t *testing.T) { + t.Run("uint8 scan from string", func(t *testing.T) { + var n SqlNull[uint8] + if err := n.Scan("200"); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if !n.Valid || n.Val != 200 { + t.Errorf("expected valid uint8=200, got valid=%v val=%d", n.Valid, n.Val) + } + }) + + t.Run("uint64 json round-trip", func(t *testing.T) { + n := Null(uint64(18446744073709551615), true) + data, err := json.Marshal(n) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var n2 SqlNull[uint64] + if err := json.Unmarshal(data, &n2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if n2.Val != n.Val { + t.Errorf("expected %d, got %d", n.Val, n2.Val) + } + }) + + t.Run("uint scan from numeric string fallback", func(t *testing.T) { + var n SqlNull[uint] + if err := n.Scan([]byte("77")); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if !n.Valid || n.Val != 77 { + t.Errorf("expected valid uint=77, got valid=%v val=%d", n.Valid, n.Val) + } + }) +} diff --git a/pkg/spectypes/sql_types_test.go b/pkg/sqltypes/sql_types_test.go similarity index 99% rename from pkg/spectypes/sql_types_test.go rename to pkg/sqltypes/sql_types_test.go index 7e743c3..8f9bca2 100644 --- a/pkg/spectypes/sql_types_test.go +++ b/pkg/sqltypes/sql_types_test.go @@ -1,4 +1,4 @@ -package spectypes +package sqltypes import ( "database/sql/driver" diff --git a/pkg/sqltypes/sql_types_yaml_xml_test.go b/pkg/sqltypes/sql_types_yaml_xml_test.go new file mode 100644 index 0000000..098bb8b --- /dev/null +++ b/pkg/sqltypes/sql_types_yaml_xml_test.go @@ -0,0 +1,678 @@ +package sqltypes + +import ( + "encoding/xml" + "testing" + "time" + + "github.com/google/uuid" + "gopkg.in/yaml.v3" +) + +// ── SqlNull: YAML ──────────────────────────────────────────────────────────── + +func TestSqlNull_YAML_Int(t *testing.T) { + n := NewSqlInt32(42) + data, err := yaml.Marshal(n) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "42\n" { + t.Errorf("expected \"42\\n\", got %q", string(data)) + } + + var n2 SqlInt32 + if err := yaml.Unmarshal(data, &n2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if n2.Int64() != 42 { + t.Errorf("expected 42, got %d", n2.Int64()) + } +} + +func TestSqlNull_YAML_Null(t *testing.T) { + var n SqlInt32 + data, err := yaml.Marshal(n) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "null\n" { + t.Errorf("expected \"null\\n\", got %q", string(data)) + } + + var n2 SqlInt32 + if err := yaml.Unmarshal([]byte("null\n"), &n2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if n2.Valid { + t.Error("expected invalid after unmarshaling null") + } + + // ~ is also a YAML null. + var n3 SqlInt32 + if err := yaml.Unmarshal([]byte("~\n"), &n3); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if n3.Valid { + t.Error("expected invalid after unmarshaling ~") + } +} + +func TestSqlNull_YAML_String(t *testing.T) { + s := NewSqlString("hello world") + data, err := yaml.Marshal(s) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var s2 SqlString + if err := yaml.Unmarshal(data, &s2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if s2.String() != "hello world" { + t.Errorf("expected %q, got %q", "hello world", s2.String()) + } +} + +func TestSqlNull_YAML_UUID(t *testing.T) { + id := uuid.New() + u := NewSqlUUID(id) + data, err := yaml.Marshal(u) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != id.String()+"\n" { + t.Errorf("expected %q, got %q", id.String()+"\n", string(data)) + } + var u2 SqlUUID + if err := yaml.Unmarshal(data, &u2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if u2.UUID() != id { + t.Errorf("expected %v, got %v", id, u2.UUID()) + } +} + +func TestSqlNull_YAML_ByteArray_Base64(t *testing.T) { + orig := []byte{0x01, 0x02, 0xFF} + b := NewSqlByteArray(orig) + data, err := yaml.Marshal(b) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var b2 SqlByteArray + if err := yaml.Unmarshal(data, &b2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if string(b2.Val) != string(orig) { + t.Errorf("expected %v, got %v", orig, b2.Val) + } +} + +// ── SqlNull: XML ───────────────────────────────────────────────────────────── + +type xmlIntWrapper struct { + XMLName xml.Name `xml:"root"` + Value SqlInt32 `xml:"value"` +} + +func TestSqlNull_XML_Int(t *testing.T) { + w := xmlIntWrapper{Value: NewSqlInt32(99)} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlIntWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if w2.Value.Int64() != 99 { + t.Errorf("expected 99, got %d", w2.Value.Int64()) + } +} + +func TestSqlNull_XML_Null(t *testing.T) { + w := xmlIntWrapper{} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlIntWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if w2.Value.Valid { + t.Errorf("expected invalid, got %v", w2.Value) + } +} + +type xmlUUIDWrapper struct { + XMLName xml.Name `xml:"root"` + ID SqlUUID `xml:"id"` +} + +func TestSqlNull_XML_UUID(t *testing.T) { + id := uuid.New() + w := xmlUUIDWrapper{ID: NewSqlUUID(id)} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlUUIDWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if w2.ID.UUID() != id { + t.Errorf("expected %v, got %v", id, w2.ID.UUID()) + } +} + +type xmlByteWrapper struct { + XMLName xml.Name `xml:"root"` + Data SqlByteArray `xml:"data"` +} + +func TestSqlNull_XML_ByteArray_Base64(t *testing.T) { + orig := []byte{0xDE, 0xAD, 0xBE, 0xEF} + w := xmlByteWrapper{Data: NewSqlByteArray(orig)} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlByteWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if string(w2.Data.Val) != string(orig) { + t.Errorf("expected %v, got %v", orig, w2.Data.Val) + } +} + +// ── SqlTimeStamp / SqlDate / SqlTime: YAML + XML ──────────────────────────── + +func TestSqlTimeStamp_YAML(t *testing.T) { + ts := NewSqlTimeStamp(time.Date(2024, 6, 15, 9, 30, 0, 0, time.UTC)) + data, err := yaml.Marshal(ts) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "2024-06-15T09:30:00\n" { + t.Errorf("unexpected YAML: %q", string(data)) + } + var ts2 SqlTimeStamp + if err := yaml.Unmarshal(data, &ts2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if ts2.Time().Format("2006-01-02T15:04:05") != "2024-06-15T09:30:00" { + t.Errorf("expected 2024-06-15T09:30:00, got %v", ts2.Time()) + } + + var zero SqlTimeStamp + zdata, err := yaml.Marshal(zero) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(zdata) != "null\n" { + t.Errorf("expected null, got %q", zdata) + } +} + +type xmlTimeStampWrapper struct { + XMLName xml.Name `xml:"root"` + At SqlTimeStamp `xml:"at"` +} + +func TestSqlTimeStamp_XML(t *testing.T) { + w := xmlTimeStampWrapper{At: NewSqlTimeStamp(time.Date(2024, 6, 15, 9, 30, 0, 0, time.UTC))} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlTimeStampWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if w2.At.Time().Format("2006-01-02T15:04:05") != "2024-06-15T09:30:00" { + t.Errorf("expected 2024-06-15T09:30:00, got %v", w2.At.Time()) + } +} + +func TestSqlDate_YAML(t *testing.T) { + d := NewSqlDate(time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC)) + data, err := yaml.Marshal(d) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "\"2024-06-15\"\n" { + t.Errorf("unexpected YAML: %q", string(data)) + } + var d2 SqlDate + if err := yaml.Unmarshal(data, &d2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if d2.String() != "2024-06-15" { + t.Errorf("expected 2024-06-15, got %q", d2.String()) + } +} + +type xmlDateWrapper struct { + XMLName xml.Name `xml:"root"` + Day SqlDate `xml:"day"` +} + +func TestSqlDate_XML(t *testing.T) { + w := xmlDateWrapper{Day: NewSqlDate(time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC))} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlDateWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if w2.Day.String() != "2024-06-15" { + t.Errorf("expected 2024-06-15, got %q", w2.Day.String()) + } +} + +func TestSqlTime_YAML(t *testing.T) { + tm := NewSqlTime(time.Date(0, 1, 1, 14, 5, 9, 0, time.UTC)) + data, err := yaml.Marshal(tm) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "\"14:05:09\"\n" { + t.Errorf("unexpected YAML: %q", string(data)) + } + var tm2 SqlTime + if err := yaml.Unmarshal(data, &tm2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if tm2.String() != "14:05:09" { + t.Errorf("expected 14:05:09, got %q", tm2.String()) + } +} + +type xmlTimeWrapper struct { + XMLName xml.Name `xml:"root"` + At SqlTime `xml:"at"` +} + +func TestSqlTime_XML(t *testing.T) { + w := xmlTimeWrapper{At: NewSqlTime(time.Date(0, 1, 1, 14, 5, 9, 0, time.UTC))} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlTimeWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if w2.At.String() != "14:05:09" { + t.Errorf("expected 14:05:09, got %q", w2.At.String()) + } +} + +// ── SqlJSONB: YAML + XML ───────────────────────────────────────────────────── + +func TestSqlJSONB_YAML(t *testing.T) { + j := SqlJSONB(`{"name":"test","count":42}`) + data, err := yaml.Marshal(j) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var decoded map[string]any + if err := yaml.Unmarshal(data, &decoded); err != nil { + t.Fatalf("failed decoding produced YAML: %v", err) + } + if decoded["name"] != "test" { + t.Errorf("expected name=test, got %v", decoded["name"]) + } + + var j2 SqlJSONB + if err := yaml.Unmarshal(data, &j2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + m, err := j2.AsMap() + if err != nil { + t.Fatalf("AsMap failed: %v", err) + } + if m["name"] != "test" { + t.Errorf("expected name=test, got %v", m["name"]) + } +} + +func TestSqlJSONB_YAML_Null(t *testing.T) { + var j SqlJSONB + data, err := yaml.Marshal(j) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "null\n" { + t.Errorf("expected null, got %q", data) + } + var j2 SqlJSONB + if err := yaml.Unmarshal([]byte("null\n"), &j2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if j2 != nil { + t.Errorf("expected nil, got %v", j2) + } +} + +type xmlJSONBWrapper struct { + XMLName xml.Name `xml:"root"` + Meta SqlJSONB `xml:"meta"` +} + +func TestSqlJSONB_XML(t *testing.T) { + w := xmlJSONBWrapper{Meta: SqlJSONB(`{"key":"value"}`)} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlJSONBWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + m, err := w2.Meta.AsMap() + if err != nil { + t.Fatalf("AsMap failed: %v", err) + } + if m["key"] != "value" { + t.Errorf("expected key=value, got %v", m) + } +} + +// ── Array types: YAML ──────────────────────────────────────────────────────── + +func TestSqlStringArray_YAML(t *testing.T) { + a := NewSqlStringArray([]string{"a", "b", "c"}) + data, err := yaml.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var a2 SqlStringArray + if err := yaml.Unmarshal(data, &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if len(a2.Val) != 3 || a2.Val[1] != "b" { + t.Errorf("unexpected value %v", a2.Val) + } +} + +func TestSqlStringArray_YAML_Null(t *testing.T) { + var a SqlStringArray + data, err := yaml.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != "null\n" { + t.Errorf("expected null, got %q", data) + } + var a2 SqlStringArray + if err := yaml.Unmarshal([]byte("null\n"), &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if a2.Valid { + t.Error("expected invalid") + } +} + +func TestSqlInt32Array_YAML(t *testing.T) { + a := NewSqlInt32Array([]int32{1, 2, 3}) + data, err := yaml.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var a2 SqlInt32Array + if err := yaml.Unmarshal(data, &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, v := range a.Val { + if a2.Val[i] != v { + t.Errorf("index %d: expected %d, got %d", i, v, a2.Val[i]) + } + } +} + +func TestSqlUUIDArray_YAML(t *testing.T) { + ids := []uuid.UUID{uuid.New(), uuid.New()} + a := NewSqlUUIDArray(ids) + data, err := yaml.Marshal(a) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var a2 SqlUUIDArray + if err := yaml.Unmarshal(data, &a2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, v := range ids { + if a2.Val[i] != v { + t.Errorf("index %d: expected %v, got %v", i, v, a2.Val[i]) + } + } +} + +func TestSqlVector_YAML(t *testing.T) { + v := NewSqlVector([]float32{0.1, 0.2, 0.3}) + data, err := yaml.Marshal(v) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var v2 SqlVector + if err := yaml.Unmarshal(data, &v2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, f := range v.Val { + if v2.Val[i] != f { + t.Errorf("index %d: expected %v, got %v", i, f, v2.Val[i]) + } + } +} + +// ── Array types: XML ───────────────────────────────────────────────────────── + +type xmlStringArrayWrapper struct { + XMLName xml.Name `xml:"root"` + Tags SqlStringArray `xml:"tags"` +} + +func TestSqlStringArray_XML(t *testing.T) { + w := xmlStringArrayWrapper{Tags: NewSqlStringArray([]string{"x", "y", "z"})} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlStringArrayWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + want := []string{"x", "y", "z"} + if len(w2.Tags.Val) != len(want) { + t.Fatalf("expected %v, got %v", want, w2.Tags.Val) + } + for i := range want { + if w2.Tags.Val[i] != want[i] { + t.Errorf("index %d: expected %q, got %q", i, want[i], w2.Tags.Val[i]) + } + } +} + +func TestSqlStringArray_XML_Empty(t *testing.T) { + w := xmlStringArrayWrapper{} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlStringArrayWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if len(w2.Tags.Val) != 0 { + t.Errorf("expected empty slice, got %v", w2.Tags.Val) + } +} + +type xmlInt64ArrayWrapper struct { + XMLName xml.Name `xml:"root"` + Scores SqlInt64Array `xml:"scores"` +} + +func TestSqlInt64Array_XML(t *testing.T) { + w := xmlInt64ArrayWrapper{Scores: NewSqlInt64Array([]int64{10, 20, 30})} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlInt64ArrayWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + want := []int64{10, 20, 30} + for i := range want { + if w2.Scores.Val[i] != want[i] { + t.Errorf("index %d: expected %d, got %d", i, want[i], w2.Scores.Val[i]) + } + } +} + +type xmlUUIDArrayWrapper struct { + XMLName xml.Name `xml:"root"` + IDs SqlUUIDArray `xml:"ids"` +} + +func TestSqlUUIDArray_XML(t *testing.T) { + ids := []uuid.UUID{uuid.New(), uuid.New()} + w := xmlUUIDArrayWrapper{IDs: NewSqlUUIDArray(ids)} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlUUIDArrayWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + for i, id := range ids { + if w2.IDs.Val[i] != id { + t.Errorf("index %d: expected %v, got %v", i, id, w2.IDs.Val[i]) + } + } +} + +type xmlVectorWrapper struct { + XMLName xml.Name `xml:"root"` + Embedding SqlVector `xml:"embedding"` +} + +func TestSqlVector_XML(t *testing.T) { + w := xmlVectorWrapper{Embedding: NewSqlVector([]float32{1.5, -2.5})} + data, err := xml.Marshal(w) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var w2 xmlVectorWrapper + if err := xml.Unmarshal(data, &w2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + want := []float32{1.5, -2.5} + for i := range want { + if w2.Embedding.Val[i] != want[i] { + t.Errorf("index %d: expected %v, got %v", i, want[i], w2.Embedding.Val[i]) + } + } +} + +// ── Combined struct round-trip ─────────────────────────────────────────────── + +type yamlXMLRecord struct { + XMLName xml.Name `xml:"record" yaml:"-" json:"-"` + ID SqlUUID `xml:"id" yaml:"id"` + Name SqlString `xml:"name" yaml:"name"` + Age SqlInt32 `xml:"age" yaml:"age"` + Active SqlBool `xml:"active" yaml:"active"` + Created SqlTimeStamp `xml:"created" yaml:"created"` + Tags SqlStringArray `xml:"tags" yaml:"tags"` +} + +func TestCombinedRecord_YAML_RoundTrip(t *testing.T) { + id := uuid.New() + original := yamlXMLRecord{ + ID: NewSqlUUID(id), + Name: NewSqlString("Ada"), + Age: NewSqlInt32(36), + Active: NewSqlBool(true), + Created: NewSqlTimeStamp(time.Date(2024, 3, 10, 12, 30, 0, 0, time.UTC)), + Tags: NewSqlStringArray([]string{"engineer", "mathematician"}), + } + + data, err := yaml.Marshal(original) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var decoded yamlXMLRecord + if err := yaml.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if decoded.ID.UUID() != id { + t.Errorf("ID: expected %v, got %v", id, decoded.ID.UUID()) + } + if decoded.Name.String() != "Ada" { + t.Errorf("Name: expected Ada, got %q", decoded.Name.String()) + } + if decoded.Age.Int64() != 36 { + t.Errorf("Age: expected 36, got %d", decoded.Age.Int64()) + } + if !decoded.Active.Bool() { + t.Error("Active: expected true") + } + if decoded.Created.Time().Format("2006-01-02T15:04:05") != "2024-03-10T12:30:00" { + t.Errorf("Created: expected 2024-03-10T12:30:00, got %v", decoded.Created.Time()) + } + if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[0] != "engineer" { + t.Errorf("Tags: unexpected value %v", decoded.Tags.Val) + } +} + +func TestCombinedRecord_XML_RoundTrip(t *testing.T) { + id := uuid.New() + original := yamlXMLRecord{ + ID: NewSqlUUID(id), + Name: NewSqlString("Ada"), + Age: NewSqlInt32(36), + Active: NewSqlBool(true), + Created: NewSqlTimeStamp(time.Date(2024, 3, 10, 12, 30, 0, 0, time.UTC)), + Tags: NewSqlStringArray([]string{"engineer", "mathematician"}), + } + + data, err := xml.Marshal(original) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var decoded yamlXMLRecord + if err := xml.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if decoded.ID.UUID() != id { + t.Errorf("ID: expected %v, got %v", id, decoded.ID.UUID()) + } + if decoded.Name.String() != "Ada" { + t.Errorf("Name: expected Ada, got %q", decoded.Name.String()) + } + if decoded.Age.Int64() != 36 { + t.Errorf("Age: expected 36, got %d", decoded.Age.Int64()) + } + if !decoded.Active.Bool() { + t.Error("Active: expected true") + } + if decoded.Created.Time().Format("2006-01-02T15:04:05") != "2024-03-10T12:30:00" { + t.Errorf("Created: expected 2024-03-10T12:30:00, got %v", decoded.Created.Time()) + } + if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[0] != "engineer" { + t.Errorf("Tags: unexpected value %v", decoded.Tags.Val) + } +} diff --git a/pkg/sqltypes/struct_json_test.go b/pkg/sqltypes/struct_json_test.go new file mode 100644 index 0000000..f273bf1 --- /dev/null +++ b/pkg/sqltypes/struct_json_test.go @@ -0,0 +1,164 @@ +package sqltypes + +import ( + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" +) + +// record mimics a typical DB model composed of sqltypes fields, exercising +// marshalling/unmarshalling of the whole set together as encoding/json would +// when used on a real struct (not just the individual types in isolation). +type record struct { + ID SqlUUID `json:"id"` + Name SqlString `json:"name"` + Bio SqlString `json:"bio"` + Age SqlInt32 `json:"age"` + Score SqlFloat64 `json:"score"` + Active SqlBool `json:"active"` + CreatedAt SqlTimeStamp `json:"created_at"` + BirthDate SqlDate `json:"birth_date"` + Avatar SqlByteArray `json:"avatar"` + Tags SqlStringArray `json:"tags"` + Scores SqlInt32Array `json:"scores"` + Metadata SqlJSONB `json:"metadata"` + Embedding SqlVector `json:"embedding"` + Extras []uuid.UUID `json:"-"` +} + +func TestStruct_JSON_RoundTrip_AllFieldsPresent(t *testing.T) { + id := uuid.New() + createdAt := time.Date(2024, 3, 10, 12, 30, 0, 0, time.UTC) + birthDate := time.Date(1990, 5, 20, 0, 0, 0, 0, time.UTC) + + original := record{ + ID: NewSqlUUID(id), + Name: NewSqlString("Ada Lovelace"), + Bio: SqlString{}, // intentionally null + Age: NewSqlInt32(36), + Score: NewSqlFloat64(98.6), + Active: NewSqlBool(true), + CreatedAt: NewSqlTimeStamp(createdAt), + BirthDate: NewSqlDate(birthDate), + Avatar: NewSqlByteArray([]byte{0xDE, 0xAD, 0xBE, 0xEF}), + Tags: NewSqlStringArray([]string{"engineer", "mathematician"}), + Scores: NewSqlInt32Array([]int32{10, 20, 30}), + Metadata: SqlJSONB(`{"role":"admin"}`), + Embedding: NewSqlVector([]float32{0.1, 0.2, 0.3}), + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var decoded record + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if decoded.ID.UUID() != id { + t.Errorf("ID: expected %v, got %v", id, decoded.ID.UUID()) + } + if decoded.Name.String() != "Ada Lovelace" { + t.Errorf("Name: expected Ada Lovelace, got %q", decoded.Name.String()) + } + if decoded.Bio.Valid { + t.Errorf("Bio: expected invalid/null, got %v", decoded.Bio) + } + if decoded.Age.Int64() != 36 { + t.Errorf("Age: expected 36, got %d", decoded.Age.Int64()) + } + if decoded.Score.Float64() != 98.6 { + t.Errorf("Score: expected 98.6, got %v", decoded.Score.Float64()) + } + if !decoded.Active.Bool() { + t.Errorf("Active: expected true") + } + if decoded.CreatedAt.Time().Format("2006-01-02T15:04:05") != createdAt.Format("2006-01-02T15:04:05") { + t.Errorf("CreatedAt: expected %v, got %v", createdAt, decoded.CreatedAt.Time()) + } + if decoded.BirthDate.String() != "1990-05-20" { + t.Errorf("BirthDate: expected 1990-05-20, got %q", decoded.BirthDate.String()) + } + if string(decoded.Avatar.Val) != string(original.Avatar.Val) { + t.Errorf("Avatar: expected %v, got %v", original.Avatar.Val, decoded.Avatar.Val) + } + if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[0] != "engineer" { + t.Errorf("Tags: unexpected value %v", decoded.Tags.Val) + } + if len(decoded.Scores.Val) != 3 || decoded.Scores.Val[2] != 30 { + t.Errorf("Scores: unexpected value %v", decoded.Scores.Val) + } + m, err := decoded.Metadata.AsMap() + if err != nil || m["role"] != "admin" { + t.Errorf("Metadata: expected role=admin, got %v (err=%v)", m, err) + } + if len(decoded.Embedding.Val) != 3 || decoded.Embedding.Val[1] != 0.2 { + t.Errorf("Embedding: unexpected value %v", decoded.Embedding.Val) + } +} + +func TestStruct_JSON_RoundTrip_AllNull(t *testing.T) { + var original record + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var decoded record + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if decoded.ID.Valid || decoded.Name.Valid || decoded.Age.Valid || decoded.Score.Valid || + decoded.Active.Valid || decoded.CreatedAt.Valid || decoded.BirthDate.Valid || + decoded.Avatar.Valid || decoded.Tags.Valid || decoded.Scores.Valid || decoded.Embedding.Valid { + t.Errorf("expected all fields invalid/null after round-trip, got %+v", decoded) + } + if decoded.Metadata != nil { + t.Errorf("expected nil Metadata, got %v", decoded.Metadata) + } +} + +func TestStruct_JSON_UnmarshalFromRawJSON(t *testing.T) { + raw := `{ + "id": "3e843d4e-6b3c-4f2e-9a1e-6f0b2f3c9d10", + "name": "Grace Hopper", + "bio": null, + "age": 85, + "score": 100, + "active": false, + "created_at": "2023-12-01T08:00:00", + "birth_date": "1906-12-09", + "avatar": "AQIDBA==", + "tags": ["navy", "compiler"], + "scores": [1, 2, 3], + "metadata": {"key": "value"}, + "embedding": [1.0, 2.0] + }` + + var decoded record + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if decoded.Name.String() != "Grace Hopper" { + t.Errorf("expected Grace Hopper, got %q", decoded.Name.String()) + } + if decoded.Age.Int64() != 85 { + t.Errorf("expected age 85, got %d", decoded.Age.Int64()) + } + if decoded.Active.Valid && decoded.Active.Bool() { + t.Errorf("expected active=false") + } + if string(decoded.Avatar.Val) != string([]byte{1, 2, 3, 4}) { + t.Errorf("expected avatar bytes [1 2 3 4], got %v", decoded.Avatar.Val) + } + if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[1] != "compiler" { + t.Errorf("unexpected tags %v", decoded.Tags.Val) + } +} diff --git a/pkg/spectypes/uuid_integration_test.go b/pkg/sqltypes/uuid_integration_test.go similarity index 99% rename from pkg/spectypes/uuid_integration_test.go rename to pkg/sqltypes/uuid_integration_test.go index b79c79b..476d1d5 100644 --- a/pkg/spectypes/uuid_integration_test.go +++ b/pkg/sqltypes/uuid_integration_test.go @@ -1,4 +1,4 @@ -package spectypes +package sqltypes import ( "database/sql" diff --git a/pkg/writers/bun/README.md b/pkg/writers/bun/README.md index 29f0d8d..35e9cce 100644 --- a/pkg/writers/bun/README.md +++ b/pkg/writers/bun/README.md @@ -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,24 +69,24 @@ 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" ) type User struct { bun.BaseModel `bun:"table:users,alias:u"` - 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"` + ID int64 `bun:"id,type:uuid,pk," json:"id"` + Username string `bun:"username,type:text,notnull," json:"username"` + 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 diff --git a/pkg/writers/bun/template_data.go b/pkg/writers/bun/template_data.go index 1b630f0..7cb4335 100644 --- a/pkg/writers/bun/template_data.go +++ b/pkg/writers/bun/template_data.go @@ -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 diff --git a/pkg/writers/bun/type_mapper.go b/pkg/writers/bun/type_mapper.go index 661bde5..e66f4ae 100644 --- a/pkg/writers/bun/type_mapper.go +++ b/pkg/writers/bun/type_mapper.go @@ -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 diff --git a/pkg/writers/bun/writer.go b/pkg/writers/bun/writer.go index 37561ac..001777d 100644 --- a/pkg/writers/bun/writer.go +++ b/pkg/writers/bun/writer.go @@ -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 diff --git a/pkg/writers/bun/writer_test.go b/pkg/writers/bun/writer_test.go index 0e66048..33018ce 100644 --- a/pkg/writers/bun/writer_test.go +++ b/pkg/writers/bun/writer_test.go @@ -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) } }) } diff --git a/pkg/writers/gorm/README.md b/pkg/writers/gorm/README.md index 6a7aa15..dd6b41c 100644 --- a/pkg/writers/gorm/README.md +++ b/pkg/writers/gorm/README.md @@ -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` | diff --git a/pkg/writers/gorm/type_mapper.go b/pkg/writers/gorm/type_mapper.go index 165d61a..24e67c7 100644 --- a/pkg/writers/gorm/type_mapper.go +++ b/pkg/writers/gorm/type_mapper.go @@ -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 diff --git a/pkg/writers/gorm/writer.go b/pkg/writers/gorm/writer.go index 6e9121e..657f861 100644 --- a/pkg/writers/gorm/writer.go +++ b/pkg/writers/gorm/writer.go @@ -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 diff --git a/pkg/writers/writer.go b/pkg/writers/writer.go index f3d3d13..01060d3 100644 --- a/pkg/writers/writer.go +++ b/pkg/writers/writer.go @@ -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.