Compare commits

...

6 Commits

Author SHA1 Message Date
warkanum 5edb004799 Merge pull request 'fix(bun): support extra generated model fields' (#5) from fix/bun-extra-fields-issue-4 into master
Reviewed-on: #5
Reviewed-by: Warky <warkanum@warky.dev>
2026-07-09 04:53:33 +00:00
Hermes Agent 764d00c249 fix(bun): read extra fields from file 2026-07-09 06:02:01 +02:00
Hermes Agent 47b77763cb fix(bun): support extra generated model fields 2026-07-09 05:50:33 +02:00
Hein 7805d9b6f0 chore(release): update package version to 1.0.62
Release / test (push) Successful in 2m34s
Release / release (push) Successful in 1m43s
Release / pkg-aur (push) Successful in 1m2s
Release / pkg-deb (push) Successful in 2m57s
Release / pkg-rpm (push) Successful in 3m2s
2026-07-07 15:29:55 +02:00
Hein 40a0e6a0aa refactor: ♻️ change resolvspec types to build in sqltypes 2026-07-07 15:28:01 +02:00
Hein 99d63aa5f4 test(uuid): add integration tests for SqlUUID with database
* Implement tests for inserting, updating, and retrieving UUIDs in a SQLite database.
* Verify that Value() returns a string representation of the UUID.
* Ensure compatibility with custom types implementing fmt.Stringer.
* Update type mapper imports to reflect new package path.
2026-07-02 17:06:41 +02:00
29 changed files with 5287 additions and 114 deletions
+13
View File
@@ -151,8 +151,21 @@ pkg/merge/ Schema merging
pkg/models/ Internal data models
pkg/transform/ Transformation logic
pkg/pgsql/ PostgreSQL utilities
pkg/sqltypes/ Nullable SQL types for generated/hand-written models (see below)
```
## Nullable Types (`pkg/sqltypes`)
The `bun` and `gorm` writers can generate model structs using
[`pkg/sqltypes`](./pkg/sqltypes/README.md) — nullable types (`SqlString`,
`SqlInt32`, `SqlTimeStamp`, `SqlStringArray`, …) that implement
`database/sql.Scanner`, `driver.Valuer`, and JSON/YAML/XML marshalling in one
type, selected via `--types sqltypes`. See the
[`pkg/sqltypes` README](./pkg/sqltypes/README.md) for the full type
reference, or the [`bun`](./pkg/writers/bun/README.md) /
[`gorm`](./pkg/writers/gorm/README.md) writer docs for the `--types` flag
(`sqltypes`, `stdlib`, or `baselib`).
## Contributing
1. Register or sign in with GitHub at [git.warky.dev](https://git.warky.dev)
+26 -3
View File
@@ -1,6 +1,7 @@
package main
import (
stdjson "encoding/json"
"fmt"
"os"
"strings"
@@ -54,6 +55,7 @@ var (
convertFlattenSchema bool
convertNullableTypes string
convertContinueOnError bool
convertExtraFields string
)
var convertCmd = &cobra.Command{
@@ -177,8 +179,9 @@ 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)")
convertCmd.Flags().StringVar(&convertExtraFields, "extra-fields", "", "Path to JSON file containing extra Bun model fields to inject (bun output only); fields support target_table, name, type, bun_tag, json_tag, comment")
err := convertCmd.MarkFlagRequired("from")
if err != nil {
@@ -245,7 +248,7 @@ func runConvert(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, " Schema: %s\n", convertSchemaFilter)
}
if err := writeDatabase(db, convertTargetType, convertTargetPath, convertPackageName, convertSchemaFilter, convertFlattenSchema, convertNullableTypes, convertContinueOnError); err != nil {
if err := writeDatabase(db, convertTargetType, convertTargetPath, convertPackageName, convertSchemaFilter, convertFlattenSchema, convertNullableTypes, convertContinueOnError, convertExtraFields); err != nil {
return fmt.Errorf("failed to write target: %w", err)
}
@@ -385,10 +388,30 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa
return db, nil
}
func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool, nullableTypes string, continueOnError bool) error {
func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool, nullableTypes string, continueOnError bool, extraFields string) error {
var writer writers.Writer
writerOpts := newWriterOptions(outputPath, packageName, flattenSchema, nullableTypes, continueOnError)
if extraFields != "" {
if strings.ToLower(dbType) != "bun" {
return fmt.Errorf("--extra-fields is only supported for Bun output")
}
extraFieldsJSON, err := os.ReadFile(extraFields)
if err != nil {
return fmt.Errorf("failed to read --extra-fields file %q: %w", extraFields, err)
}
var parsed []wbun.ExtraFieldConfig
if err := stdjson.Unmarshal(extraFieldsJSON, &parsed); err != nil {
return fmt.Errorf("invalid --extra-fields JSON in %q: %w", extraFields, err)
}
if len(parsed) == 0 {
return fmt.Errorf("--extra-fields must contain at least one field")
}
writerOpts.Metadata = map[string]interface{}{
"extra_fields": string(extraFieldsJSON),
}
}
switch strings.ToLower(dbType) {
case "dbml":
+2 -1
View File
@@ -111,7 +111,7 @@ func init() {
splitCmd.Flags().StringVar(&splitTables, "tables", "", "Comma-separated list of table names to include (case-insensitive)")
splitCmd.Flags().StringVar(&splitExcludeSchema, "exclude-schema", "", "Comma-separated list of schema names to exclude")
splitCmd.Flags().StringVar(&splitExcludeTables, "exclude-tables", "", "Comma-separated list of table names to exclude (case-insensitive)")
splitCmd.Flags().StringVar(&splitNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'baselib' (default, Go pointer types), 'stdlib' (database/sql), or 'resolvespec'")
splitCmd.Flags().StringVar(&splitNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'baselib' (default, Go pointer types), 'stdlib' (database/sql), or 'sqltypes'")
err := splitCmd.MarkFlagRequired("from")
if err != nil {
@@ -189,6 +189,7 @@ func runSplit(cmd *cobra.Command, args []string) error {
false, // no flatten-schema for split
splitNullableTypes,
false, // no continue-on-error for split
"", // no extra fields for split
)
if err != nil {
return fmt.Errorf("failed to write output: %w", err)
+11 -11
View File
@@ -3,17 +3,17 @@ package models_bun
// //ModelCoreMasterprocess - Generated Table for Schema core
// type ModelCoreMasterprocess struct {
// bun.BaseModel `bun:"table:core.masterprocess,alias:masterprocess"`
// Description resolvespec_common.SqlString `json:"description" bun:"description,type:citext,"`
// GUID resolvespec_common.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Inactive resolvespec_common.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
// Jsonvalue resolvespec_common.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
// Ridjsonschema resolvespec_common.SqlInt32 `json:"rid_jsonschema" bun:"rid_jsonschema,type:integer,"`
// Ridmasterprocess resolvespec_common.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,pk,default:nextval('core.identity_masterprocess_rid_masterprocess'::regclass),"`
// Ridmastertypehubtype resolvespec_common.SqlInt32 `json:"rid_mastertype_hubtype" bun:"rid_mastertype_hubtype,type:integer,"`
// Ridmastertypeprocesstype resolvespec_common.SqlInt32 `json:"rid_mastertype_processtype" bun:"rid_mastertype_processtype,type:integer,"`
// Ridprogrammodule resolvespec_common.SqlInt32 `json:"rid_programmodule" bun:"rid_programmodule,type:integer,"`
// Sequenceno resolvespec_common.SqlInt32 `json:"sequenceno" bun:"sequenceno,type:integer,"`
// Singleprocess resolvespec_common.SqlInt16 `json:"singleprocess" bun:"singleprocess,type:smallint,"`
// Description sql_types.SqlString `json:"description" bun:"description,type:citext,"`
// GUID sql_types.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Inactive sql_types.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
// Jsonvalue sql_types.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
// Ridjsonschema sql_types.SqlInt32 `json:"rid_jsonschema" bun:"rid_jsonschema,type:integer,"`
// Ridmasterprocess sql_types.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,pk,default:nextval('core.identity_masterprocess_rid_masterprocess'::regclass),"`
// Ridmastertypehubtype sql_types.SqlInt32 `json:"rid_mastertype_hubtype" bun:"rid_mastertype_hubtype,type:integer,"`
// Ridmastertypeprocesstype sql_types.SqlInt32 `json:"rid_mastertype_processtype" bun:"rid_mastertype_processtype,type:integer,"`
// Ridprogrammodule sql_types.SqlInt32 `json:"rid_programmodule" bun:"rid_programmodule,type:integer,"`
// Sequenceno sql_types.SqlInt32 `json:"sequenceno" bun:"sequenceno,type:integer,"`
// Singleprocess sql_types.SqlInt16 `json:"singleprocess" bun:"singleprocess,type:smallint,"`
// Updatecnt int64 `json:"updatecnt" bun:"updatecnt,type:integer,default:0,"`
// JSON *ModelCoreJsonschema `json:"JSON,omitempty" bun:"rel:has-one,join:rid_jsonschema=rid_jsonschema"`
// MTT_RID_MASTERTYPE_HUBTYPE *ModelCoreMastertype `json:"MTT_RID_MASTERTYPE_HUBTYPE,omitempty" bun:"rel:has-one,join:rid_mastertype_hubtype=rid_mastertype"`
+20 -20
View File
@@ -3,26 +3,26 @@ package models_bun
// //ModelCoreMastertask - Generated Table for Schema core
// type ModelCoreMastertask struct {
// bun.BaseModel `bun:"table:core.mastertask,alias:mastertask"`
// Allactionsmustcomplete resolvespec_common.SqlInt16 `json:"allactionsmustcomplete" bun:"allactionsmustcomplete,type:smallint,"`
// Condition resolvespec_common.SqlString `json:"condition" bun:"condition,type:citext,"`
// Description resolvespec_common.SqlString `json:"description" bun:"description,type:citext,"`
// Dueday resolvespec_common.SqlInt16 `json:"dueday" bun:"dueday,type:smallint,"`
// Dueoption resolvespec_common.SqlString `json:"dueoption" bun:"dueoption,type:citext,"`
// Escalation resolvespec_common.SqlInt32 `json:"escalation" bun:"escalation,type:integer,"`
// Escalationoption resolvespec_common.SqlString `json:"escalationoption" bun:"escalationoption,type:citext,"`
// GUID resolvespec_common.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Inactive resolvespec_common.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
// Jsonvalue resolvespec_common.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
// Mastertasknote resolvespec_common.SqlString `json:"mastertasknote" bun:"mastertasknote,type:citext,"`
// Repeatinterval resolvespec_common.SqlInt16 `json:"repeatinterval" bun:"repeatinterval,type:smallint,"`
// Repeattype resolvespec_common.SqlString `json:"repeattype" bun:"repeattype,type:citext,"`
// Ridjsonschema resolvespec_common.SqlInt32 `json:"rid_jsonschema" bun:"rid_jsonschema,type:integer,"`
// Ridmasterprocess resolvespec_common.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,"`
// Ridmastertask resolvespec_common.SqlInt32 `json:"rid_mastertask" bun:"rid_mastertask,type:integer,pk,default:nextval('core.identity_mastertask_rid_mastertask'::regclass),"`
// Ridmastertypetasktype resolvespec_common.SqlInt32 `json:"rid_mastertype_tasktype" bun:"rid_mastertype_tasktype,type:integer,"`
// Sequenceno resolvespec_common.SqlInt32 `json:"sequenceno" bun:"sequenceno,type:integer,"`
// Singletask resolvespec_common.SqlInt16 `json:"singletask" bun:"singletask,type:smallint,"`
// Startday resolvespec_common.SqlInt16 `json:"startday" bun:"startday,type:smallint,"`
// Allactionsmustcomplete sql_types.SqlInt16 `json:"allactionsmustcomplete" bun:"allactionsmustcomplete,type:smallint,"`
// Condition sql_types.SqlString `json:"condition" bun:"condition,type:citext,"`
// Description sql_types.SqlString `json:"description" bun:"description,type:citext,"`
// Dueday sql_types.SqlInt16 `json:"dueday" bun:"dueday,type:smallint,"`
// Dueoption sql_types.SqlString `json:"dueoption" bun:"dueoption,type:citext,"`
// Escalation sql_types.SqlInt32 `json:"escalation" bun:"escalation,type:integer,"`
// Escalationoption sql_types.SqlString `json:"escalationoption" bun:"escalationoption,type:citext,"`
// GUID sql_types.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Inactive sql_types.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
// Jsonvalue sql_types.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
// Mastertasknote sql_types.SqlString `json:"mastertasknote" bun:"mastertasknote,type:citext,"`
// Repeatinterval sql_types.SqlInt16 `json:"repeatinterval" bun:"repeatinterval,type:smallint,"`
// Repeattype sql_types.SqlString `json:"repeattype" bun:"repeattype,type:citext,"`
// Ridjsonschema sql_types.SqlInt32 `json:"rid_jsonschema" bun:"rid_jsonschema,type:integer,"`
// Ridmasterprocess sql_types.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,"`
// Ridmastertask sql_types.SqlInt32 `json:"rid_mastertask" bun:"rid_mastertask,type:integer,pk,default:nextval('core.identity_mastertask_rid_mastertask'::regclass),"`
// Ridmastertypetasktype sql_types.SqlInt32 `json:"rid_mastertype_tasktype" bun:"rid_mastertype_tasktype,type:integer,"`
// Sequenceno sql_types.SqlInt32 `json:"sequenceno" bun:"sequenceno,type:integer,"`
// Singletask sql_types.SqlInt16 `json:"singletask" bun:"singletask,type:smallint,"`
// Startday sql_types.SqlInt16 `json:"startday" bun:"startday,type:smallint,"`
// Updatecnt int64 `json:"updatecnt" bun:"updatecnt,type:integer,default:0,"`
// JSON *ModelCoreJsonschema `json:"JSON,omitempty" bun:"rel:has-one,join:rid_jsonschema=rid_jsonschema"`
// MPR *ModelCoreMasterprocess `json:"MPR,omitempty" bun:"rel:has-one,join:rid_masterprocess=rid_masterprocess"`
+12 -12
View File
@@ -3,18 +3,18 @@ package models_bun
// //ModelCoreMastertype - Generated Table for Schema core
// type ModelCoreMastertype struct {
// bun.BaseModel `bun:"table:core.mastertype,alias:mastertype"`
// Category resolvespec_common.SqlString `json:"category" bun:"category,type:citext,"`
// Description resolvespec_common.SqlString `json:"description" bun:"description,type:citext,"`
// Disableedit resolvespec_common.SqlInt16 `json:"disableedit" bun:"disableedit,type:smallint,"`
// Forprefix resolvespec_common.SqlString `json:"forprefix" bun:"forprefix,type:citext,"`
// GUID resolvespec_common.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Hidden resolvespec_common.SqlInt16 `json:"hidden" bun:"hidden,type:smallint,"`
// Inactive resolvespec_common.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
// Jsonvalue resolvespec_common.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
// Mastertype resolvespec_common.SqlString `json:"mastertype" bun:"mastertype,type:citext,"`
// Note resolvespec_common.SqlString `json:"note" bun:"note,type:citext,"`
// Ridmastertype resolvespec_common.SqlInt32 `json:"rid_mastertype" bun:"rid_mastertype,type:integer,pk,default:nextval('core.identity_mastertype_rid_mastertype'::regclass),"`
// Ridparent resolvespec_common.SqlInt32 `json:"rid_parent" bun:"rid_parent,type:integer,"`
// Category sql_types.SqlString `json:"category" bun:"category,type:citext,"`
// Description sql_types.SqlString `json:"description" bun:"description,type:citext,"`
// Disableedit sql_types.SqlInt16 `json:"disableedit" bun:"disableedit,type:smallint,"`
// Forprefix sql_types.SqlString `json:"forprefix" bun:"forprefix,type:citext,"`
// GUID sql_types.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Hidden sql_types.SqlInt16 `json:"hidden" bun:"hidden,type:smallint,"`
// Inactive sql_types.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
// Jsonvalue sql_types.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
// Mastertype sql_types.SqlString `json:"mastertype" bun:"mastertype,type:citext,"`
// Note sql_types.SqlString `json:"note" bun:"note,type:citext,"`
// Ridmastertype sql_types.SqlInt32 `json:"rid_mastertype" bun:"rid_mastertype,type:integer,pk,default:nextval('core.identity_mastertype_rid_mastertype'::regclass),"`
// Ridparent sql_types.SqlInt32 `json:"rid_parent" bun:"rid_parent,type:integer,"`
// Updatecnt int64 `json:"updatecnt" bun:"updatecnt,type:integer,default:0,"`
// MTT *ModelCoreMastertype `json:"MTT,omitempty" bun:"rel:has-one,join:rid_mastertype=rid_parent"`
+8 -8
View File
@@ -3,15 +3,15 @@ package models_bun
// //ModelCoreProcess - Generated Table for Schema core
// type ModelCoreProcess struct {
// bun.BaseModel `bun:"table:core.process,alias:process"`
// Completedate resolvespec_common.SqlDate `json:"completedate" bun:"completedate,type:date,"`
// Completedate sql_types.SqlDate `json:"completedate" bun:"completedate,type:date,"`
// Completetime types.CustomIntTime `json:"completetime" bun:"completetime,type:integer,"`
// Description resolvespec_common.SqlString `json:"description" bun:"description,type:citext,"`
// GUID resolvespec_common.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Ridcompleteuser resolvespec_common.SqlInt32 `json:"rid_completeuser" bun:"rid_completeuser,type:integer,"`
// Ridhub resolvespec_common.SqlInt32 `json:"rid_hub" bun:"rid_hub,type:integer,"`
// Ridmasterprocess resolvespec_common.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,"`
// Ridprocess resolvespec_common.SqlInt32 `json:"rid_process" bun:"rid_process,type:integer,pk,default:nextval('core.identity_process_rid_process'::regclass),"`
// Status resolvespec_common.SqlString `json:"status" bun:"status,type:citext,"`
// Description sql_types.SqlString `json:"description" bun:"description,type:citext,"`
// GUID sql_types.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
// Ridcompleteuser sql_types.SqlInt32 `json:"rid_completeuser" bun:"rid_completeuser,type:integer,"`
// Ridhub sql_types.SqlInt32 `json:"rid_hub" bun:"rid_hub,type:integer,"`
// Ridmasterprocess sql_types.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,"`
// Ridprocess sql_types.SqlInt32 `json:"rid_process" bun:"rid_process,type:integer,pk,default:nextval('core.identity_process_rid_process'::regclass),"`
// Status sql_types.SqlString `json:"status" bun:"status,type:citext,"`
// Updatecnt int64 `json:"updatecnt" bun:"updatecnt,type:integer,default:0,"`
// HUB *ModelCoreHub `json:"HUB,omitempty" bun:"rel:has-one,join:rid_hub=rid_hub"`
// MPR *ModelCoreMasterprocess `json:"MPR,omitempty" bun:"rel:has-one,join:rid_masterprocess=rid_masterprocess"`
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=relspec
pkgver=1.0.61
pkgver=1.0.62
pkgrel=1
pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs."
arch=('x86_64' 'aarch64')
+1 -1
View File
@@ -1,5 +1,5 @@
Name: relspec
Version: 1.0.61
Version: 1.0.62
Release: 1%{?dist}
Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
+2 -2
View File
@@ -746,7 +746,7 @@ func (r *Reader) goTypeToSQL(expr ast.Expr) string {
if t.Sel.Name == "Time" {
return "timestamp"
}
case "resolvespec_common", "sql_types":
case "sql_types":
return r.sqlTypeToSQL(t.Sel.Name)
}
}
@@ -787,7 +787,7 @@ func (r *Reader) isNullableGoType(expr ast.Expr) bool {
case *ast.SelectorExpr:
// Check for sql_types nullable types
if ident, ok := t.X.(*ast.Ident); ok {
if ident.Name == "resolvespec_common" || ident.Name == "sql_types" {
if ident.Name == "sql_types" {
return strings.HasPrefix(t.Sel.Name, "Sql")
}
}
+136
View File
@@ -0,0 +1,136 @@
# sqltypes
Nullable SQL types for hand-written or generated Go models. Each type wraps a
value with a `Valid` flag and implements `database/sql.Scanner`,
`driver.Valuer`, `encoding/json`, `gopkg.in/yaml.v3`, and `encoding/xml`
marshalling — so a single struct field can be scanned from a database row,
round-tripped through JSON/YAML/XML, and written back to the database without
any per-format glue code.
This package is what the `bun` and `gorm` writers emit when generating models
with `--types sqltypes` (see [`pkg/writers/bun`](../writers/bun/README.md) and
[`pkg/writers/gorm`](../writers/gorm/README.md)). It can also be imported
directly in hand-written models.
## Import
```go
import sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
```
## Scalar types
All scalar types are instantiations of the generic `SqlNull[T]`:
| Type | Underlying | Typical SQL type |
|---|---|---|
| `SqlInt16` | `int16` | `smallint` |
| `SqlInt32` | `int32` | `integer` |
| `SqlInt64` | `int64` | `bigint` |
| `SqlFloat32` | `float32` | `real`, `float4` |
| `SqlFloat64` | `float64` | `double precision`, `numeric`, `decimal`, `money` |
| `SqlBool` | `bool` | `boolean` |
| `SqlString` | `string` | `text`, `varchar`, `char`, `citext`, `inet`, `cidr`, `macaddr` |
| `SqlByteArray` | `[]byte` | `bytea` (base64-encoded in JSON/YAML/XML) |
| `SqlUUID` | `uuid.UUID` (`github.com/google/uuid`) | `uuid` |
You can also instantiate `SqlNull[T]` directly for any type not covered
above, e.g. `SqlNull[MyEnum]`.
### Date/time types
Plain `time.Time` doesn't distinguish date-only, time-only, and timestamp
semantics, and its zero value marshals to a confusing `0001-01-01T00:00:00Z`.
These wrapper types fix both problems:
| Type | Format | Notes |
|---|---|---|
| `SqlTimeStamp` | `2006-01-02T15:04:05` | Full timestamp |
| `SqlDate` | `2006-01-02` | Date only |
| `SqlTime` | `15:04:05` | Time only |
Zero/pre-epoch values (`time.Time{}` or anything before `0002-01-01`) marshal
to `null` and `Value()` returns `nil`, instead of leaking Go's zero-time
sentinel into the database or API responses.
### JSON types
| Type | Underlying | Notes |
|---|---|---|
| `SqlJSONB` | `[]byte` | Raw JSON bytes; `MarshalYAML` decodes to native YAML mappings/sequences instead of an embedded JSON string |
| `SqlJSON` | `= SqlJSONB` | Alias — PostgreSQL's `json` and `jsonb` share the same Go representation |
`SqlJSONB` has `AsMap()` / `AsSlice()` helpers for pulling out
`map[string]any` / `[]any` without a separate `json.Unmarshal` call.
### Vector type (pgvector)
`SqlVector` wraps `[]float32` for the `vector` column type ([pgvector](https://github.com/pgvector/pgvector)),
scanning/writing the `[1,2,3]` literal format pgvector uses over the wire.
## Array types
PostgreSQL array columns (`text[]`, `integer[]`, …) map to `SqlXxxArray`
types, each wrapping `Val []T` + `Valid bool` and handling PostgreSQL's
`{a,b,c}` array literal format on `Scan`/`Value`:
`SqlStringArray`, `SqlInt16Array`, `SqlInt32Array`, `SqlInt64Array`,
`SqlFloat32Array`, `SqlFloat64Array`, `SqlBoolArray`, `SqlUUIDArray`.
## Constructing values
Every type has a `NewSqlXxx(v)` constructor that sets `Valid: true`:
```go
name := sql_types.NewSqlString("Ada Lovelace")
age := sql_types.NewSqlInt32(36)
tags := sql_types.NewSqlStringArray([]string{"engineer", "mathematician"})
```
The zero value of any type (`sql_types.SqlString{}`) is null/invalid — use it
directly for a `NULL` field instead of a separate constructor.
Generic helpers:
```go
sql_types.Null(v, valid) // SqlNull[T]{Val: v, Valid: valid}
sql_types.NewSql[T](anyValue) // best-effort conversion from any Go value
```
## Reading values back
Each scalar type has typed accessors that return the zero value instead of
panicking when `Valid` is false:
```go
n.Int64() // SqlInt16/32/64, SqlFloat32/64, SqlBool, SqlString → int64
n.Float64() // → float64
n.Bool() // → bool
n.Time() // SqlNull[time.Time]-based types → time.Time
n.UUID() // SqlUUID → uuid.UUID
n.String() // fmt.Stringer — empty string when invalid
```
## Example
```go
type User struct {
ID sql_types.SqlUUID `json:"id"`
Name sql_types.SqlString `json:"name"`
Tags sql_types.SqlStringArray `json:"tags"`
Metadata sql_types.SqlJSONB `json:"metadata"`
CreatedAt sql_types.SqlTimeStamp `json:"created_at"`
}
u := User{
ID: sql_types.NewSqlUUID(uuid.New()),
Name: sql_types.NewSqlString("Ada Lovelace"),
Tags: sql_types.NewSqlStringArray([]string{"engineer"}),
CreatedAt: sql_types.SqlTimeStampNow(),
}
// Metadata left as the zero value → serializes as null, scans as NULL.
```
Every type implements `sql.Scanner` and `driver.Valuer`, so these fields can
be used directly as struct fields with `database/sql`, `bun`, or `gorm`
without additional tags or hooks.
File diff suppressed because it is too large Load Diff
+485
View File
@@ -0,0 +1,485 @@
package sqltypes
import (
"encoding/json"
"testing"
"github.com/google/uuid"
)
func TestParsePostgresArrayElements(t *testing.T) {
tests := []struct {
name string
input string
want []string
wantErr bool
}{
{"simple", "{a,b,c}", []string{"a", "b", "c"}, false},
{"empty array", "{}", []string{}, false},
{"null", "NULL", nil, false},
{"lowercase null", "null", nil, false},
{"empty string", "", nil, false},
{"quoted with comma", `{a,"b,c",d}`, []string{"a", "b,c", "d"}, false},
{"escaped quote", `{"a""b"}`, []string{`a"b`}, false},
{"escaped backslash", `{"a\\b"}`, []string{`a\b`}, false},
{"not an array", "abc", nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parsePostgresArrayElements(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tt.want) {
t.Fatalf("expected %v, got %v", tt.want, got)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("index %d: expected %q, got %q", i, tt.want[i], got[i])
}
}
})
}
}
func TestFormatPostgresStringArray(t *testing.T) {
tests := []struct {
name string
input []string
want string
}{
{"nil", nil, "NULL"},
{"empty", []string{}, "{}"},
{"simple", []string{"a", "b"}, "{a,b}"},
{"needs quoting comma", []string{"a,b"}, `{"a,b"}`},
{"needs quoting empty elem", []string{""}, `{""}`},
{"needs quoting quote", []string{`a"b`}, `{"a""b"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatPostgresStringArray(tt.input)
if got != tt.want {
t.Errorf("expected %q, got %q", tt.want, got)
}
})
}
}
func TestSqlStringArray(t *testing.T) {
t.Run("scan and value round-trip", func(t *testing.T) {
var a SqlStringArray
if err := a.Scan(`{a,"b,c",d}`); err != nil {
t.Fatalf("Scan failed: %v", err)
}
want := []string{"a", "b,c", "d"}
if len(a.Val) != len(want) {
t.Fatalf("expected %v, got %v", want, a.Val)
}
for i := range want {
if a.Val[i] != want[i] {
t.Errorf("index %d: expected %q, got %q", i, want[i], a.Val[i])
}
}
val, err := a.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
var b SqlStringArray
if err := b.Scan(val); err != nil {
t.Fatalf("re-scan failed: %v", err)
}
for i := range want {
if b.Val[i] != want[i] {
t.Errorf("round-trip index %d: expected %q, got %q", i, want[i], b.Val[i])
}
}
})
t.Run("scan nil", func(t *testing.T) {
var a SqlStringArray
if err := a.Scan(nil); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if a.Valid {
t.Error("expected invalid")
}
})
t.Run("value invalid", func(t *testing.T) {
a := SqlStringArray{Valid: false}
val, err := a.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
if val != nil {
t.Errorf("expected nil, got %v", val)
}
})
t.Run("scan wrong type", func(t *testing.T) {
var a SqlStringArray
if err := a.Scan(42); err == nil {
t.Error("expected error for unsupported scan type")
}
})
t.Run("json round-trip", func(t *testing.T) {
a := NewSqlStringArray([]string{"x", "y", "z"})
data, err := json.Marshal(a)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != `["x","y","z"]` {
t.Errorf("unexpected JSON: %s", data)
}
var a2 SqlStringArray
if err := json.Unmarshal(data, &a2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if !a2.Valid || len(a2.Val) != 3 {
t.Fatalf("expected 3 valid elements, got %v", a2)
}
})
t.Run("json null", func(t *testing.T) {
var a SqlStringArray
data, err := json.Marshal(a)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != "null" {
t.Errorf("expected null, got %s", data)
}
var a2 SqlStringArray
if err := json.Unmarshal([]byte("null"), &a2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if a2.Valid {
t.Error("expected invalid after unmarshaling null")
}
})
t.Run("json unmarshal invalid type errors", func(t *testing.T) {
var a SqlStringArray
if err := json.Unmarshal([]byte(`42`), &a); err == nil {
t.Error("expected error unmarshaling non-array JSON")
}
})
}
func TestSqlInt16Array(t *testing.T) {
t.Run("scan and value", func(t *testing.T) {
var a SqlInt16Array
if err := a.Scan("{1,2,-3}"); err != nil {
t.Fatalf("Scan failed: %v", err)
}
want := []int16{1, 2, -3}
for i := range want {
if a.Val[i] != want[i] {
t.Errorf("index %d: expected %d, got %d", i, want[i], a.Val[i])
}
}
val, err := a.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
if val != "{1,2,-3}" {
t.Errorf("expected {1,2,-3}, got %v", val)
}
})
t.Run("scan invalid element", func(t *testing.T) {
var a SqlInt16Array
if err := a.Scan("{1,abc}"); err == nil {
t.Error("expected error for non-numeric element")
}
})
t.Run("json round-trip", func(t *testing.T) {
a := NewSqlInt16Array([]int16{5, 10, 15})
data, err := json.Marshal(a)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var a2 SqlInt16Array
if err := json.Unmarshal(data, &a2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
for i, v := range a.Val {
if a2.Val[i] != v {
t.Errorf("index %d: expected %d, got %d", i, v, a2.Val[i])
}
}
})
}
func TestSqlInt32Array(t *testing.T) {
a := NewSqlInt32Array([]int32{100000, -200000})
val, err := a.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
var b SqlInt32Array
if err := b.Scan(val); err != nil {
t.Fatalf("Scan failed: %v", err)
}
for i, v := range a.Val {
if b.Val[i] != v {
t.Errorf("index %d: expected %d, got %d", i, v, b.Val[i])
}
}
data, err := json.Marshal(a)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var c SqlInt32Array
if err := json.Unmarshal(data, &c); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
for i, v := range a.Val {
if c.Val[i] != v {
t.Errorf("index %d: expected %d, got %d", i, v, c.Val[i])
}
}
}
func TestSqlInt64Array(t *testing.T) {
a := NewSqlInt64Array([]int64{9223372036854775807, -9223372036854775808})
val, err := a.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
var b SqlInt64Array
if err := b.Scan(val); err != nil {
t.Fatalf("Scan failed: %v", err)
}
for i, v := range a.Val {
if b.Val[i] != v {
t.Errorf("index %d: expected %d, got %d", i, v, b.Val[i])
}
}
t.Run("null json", func(t *testing.T) {
var n SqlInt64Array
data, _ := json.Marshal(n)
if string(data) != "null" {
t.Errorf("expected null, got %s", data)
}
})
}
func TestSqlFloat32Array(t *testing.T) {
a := NewSqlFloat32Array([]float32{1.5, -2.25, 0})
val, err := a.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
var b SqlFloat32Array
if err := b.Scan(val); err != nil {
t.Fatalf("Scan failed: %v", err)
}
for i, v := range a.Val {
if b.Val[i] != v {
t.Errorf("index %d: expected %v, got %v", i, v, b.Val[i])
}
}
data, err := json.Marshal(a)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var c SqlFloat32Array
if err := json.Unmarshal(data, &c); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
for i, v := range a.Val {
if c.Val[i] != v {
t.Errorf("index %d: expected %v, got %v", i, v, c.Val[i])
}
}
}
func TestSqlFloat64Array(t *testing.T) {
a := NewSqlFloat64Array([]float64{3.14159, -2.71828})
val, err := a.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
var b SqlFloat64Array
if err := b.Scan(val); err != nil {
t.Fatalf("Scan failed: %v", err)
}
for i, v := range a.Val {
if b.Val[i] != v {
t.Errorf("index %d: expected %v, got %v", i, v, b.Val[i])
}
}
}
func TestSqlBoolArray(t *testing.T) {
t.Run("scan various truthy forms", func(t *testing.T) {
var a SqlBoolArray
if err := a.Scan("{t,f,true,false,1,0,yes}"); err != nil {
t.Fatalf("Scan failed: %v", err)
}
want := []bool{true, false, true, false, true, false, true}
for i := range want {
if a.Val[i] != want[i] {
t.Errorf("index %d: expected %v, got %v", i, want[i], a.Val[i])
}
}
})
t.Run("value formatting", func(t *testing.T) {
a := NewSqlBoolArray([]bool{true, false})
val, err := a.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
if val != "{t,f}" {
t.Errorf("expected {t,f}, got %v", val)
}
})
t.Run("json round-trip", func(t *testing.T) {
a := NewSqlBoolArray([]bool{true, false, true})
data, err := json.Marshal(a)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var a2 SqlBoolArray
if err := json.Unmarshal(data, &a2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
for i, v := range a.Val {
if a2.Val[i] != v {
t.Errorf("index %d: expected %v, got %v", i, v, a2.Val[i])
}
}
})
}
func TestSqlUUIDArray(t *testing.T) {
u1, u2 := uuid.New(), uuid.New()
t.Run("scan and value round-trip", func(t *testing.T) {
a := NewSqlUUIDArray([]uuid.UUID{u1, u2})
val, err := a.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
var b SqlUUIDArray
if err := b.Scan(val); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if b.Val[0] != u1 || b.Val[1] != u2 {
t.Errorf("expected [%v %v], got %v", u1, u2, b.Val)
}
})
t.Run("scan invalid uuid element", func(t *testing.T) {
var a SqlUUIDArray
if err := a.Scan("{not-a-uuid}"); err == nil {
t.Error("expected error for invalid uuid element")
}
})
t.Run("json round-trip", func(t *testing.T) {
a := NewSqlUUIDArray([]uuid.UUID{u1, u2})
data, err := json.Marshal(a)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var a2 SqlUUIDArray
if err := json.Unmarshal(data, &a2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if a2.Val[0] != u1 || a2.Val[1] != u2 {
t.Errorf("expected [%v %v], got %v", u1, u2, a2.Val)
}
})
}
func TestSqlVector(t *testing.T) {
t.Run("scan and value round-trip", func(t *testing.T) {
var v SqlVector
if err := v.Scan("[1,2.5,-3]"); err != nil {
t.Fatalf("Scan failed: %v", err)
}
want := []float32{1, 2.5, -3}
for i := range want {
if v.Val[i] != want[i] {
t.Errorf("index %d: expected %v, got %v", i, want[i], v.Val[i])
}
}
val, err := v.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
if val != "[1,2.5,-3]" {
t.Errorf("expected [1,2.5,-3], got %v", val)
}
})
t.Run("scan empty vector", func(t *testing.T) {
var v SqlVector
if err := v.Scan("[]"); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if !v.Valid || len(v.Val) != 0 {
t.Errorf("expected valid empty vector, got %v", v)
}
})
t.Run("scan invalid literal", func(t *testing.T) {
var v SqlVector
if err := v.Scan("not-a-vector"); err == nil {
t.Error("expected error for invalid vector literal")
}
})
t.Run("json round-trip", func(t *testing.T) {
v := NewSqlVector([]float32{0.1, 0.2, 0.3})
data, err := json.Marshal(v)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var v2 SqlVector
if err := json.Unmarshal(data, &v2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
for i, f := range v.Val {
if v2.Val[i] != f {
t.Errorf("index %d: expected %v, got %v", i, f, v2.Val[i])
}
}
})
t.Run("json null", func(t *testing.T) {
var v SqlVector
data, err := json.Marshal(v)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != "null" {
t.Errorf("expected null, got %s", data)
}
var v2 SqlVector
if err := json.Unmarshal([]byte("null"), &v2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if v2.Valid {
t.Error("expected invalid after unmarshaling null")
}
})
}
+981
View File
@@ -0,0 +1,981 @@
// Package sqltypes provides nullable SQL types with automatic casting and conversion methods.
package sqltypes
import (
"database/sql"
"database/sql/driver"
"encoding/base64"
"encoding/json"
"encoding/xml"
"fmt"
"reflect"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"gopkg.in/yaml.v3"
)
// tryParseDT attempts to parse a string into a time.Time using various formats.
func tryParseDT(str string) (time.Time, error) {
var lasterror error
tryFormats := []string{
time.RFC3339,
"2006-01-02T15:04:05.000-0700",
"2006-01-02T15:04:05.000",
"06-01-02T15:04:05.000",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
"02/01/2006",
"02-01-2006",
"2006-01-02",
"15:04:05.000",
"15:04:05",
"15:04",
}
for _, f := range tryFormats {
tx, err := time.Parse(f, str)
if err == nil {
return tx, nil
}
lasterror = err
}
return time.Time{}, lasterror // Return zero time on failure
}
// ToJSONDT formats a time.Time to RFC3339 string.
func ToJSONDT(dt time.Time) string {
return dt.Format(time.RFC3339)
}
// SqlNull is a generic nullable type that behaves like sql.NullXXX with auto-casting.
type SqlNull[T any] struct {
Val T
Valid bool
}
// Scan implements sql.Scanner.
func (n *SqlNull[T]) Scan(value any) error {
if value == nil {
n.Valid = false
n.Val = *new(T)
return nil
}
// Check if T is []byte, and decode base64 if applicable
// Do this BEFORE trying sql.Null to ensure base64 is handled
var zero T
if _, ok := any(zero).([]byte); ok {
// For []byte types, try to decode from base64
var strVal string
switch v := value.(type) {
case string:
strVal = v
case []byte:
strVal = string(v)
default:
strVal = fmt.Sprintf("%v", value)
}
// Try base64 decode
if decoded, err := base64.StdEncoding.DecodeString(strVal); err == nil {
n.Val = any(decoded).(T)
n.Valid = true
return nil
}
// Fallback to raw bytes
n.Val = any([]byte(strVal)).(T)
n.Valid = true
return nil
}
// Try standard sql.Null[T] for other types.
var sqlNull sql.Null[T]
if err := sqlNull.Scan(value); err == nil {
n.Val = sqlNull.V
n.Valid = sqlNull.Valid
return nil
}
// Fallback: parse from string/bytes.
switch v := value.(type) {
case string:
return n.FromString(v)
case []byte:
return n.FromString(string(v))
case float32, float64:
return n.FromString(fmt.Sprintf("%f", value))
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return n.FromString(fmt.Sprintf("%d", value))
default:
return n.FromString(fmt.Sprintf("%v", value))
}
}
func (n *SqlNull[T]) FromString(s string) error {
s = strings.TrimSpace(s)
n.Valid = false
n.Val = *new(T)
if s == "" || strings.EqualFold(s, "null") {
return nil
}
var zero T
switch any(zero).(type) {
case int, int8, int16, int32, int64:
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
reflect.ValueOf(&n.Val).Elem().SetInt(i)
n.Valid = true
} else if f, err := strconv.ParseFloat(s, 64); err == nil {
reflect.ValueOf(&n.Val).Elem().SetInt(int64(f))
n.Valid = true
}
case uint, uint8, uint16, uint32, uint64:
if u, err := strconv.ParseUint(s, 10, 64); err == nil {
reflect.ValueOf(&n.Val).Elem().SetUint(u)
n.Valid = true
} else if f, err := strconv.ParseFloat(s, 64); err == nil && f >= 0 {
reflect.ValueOf(&n.Val).Elem().SetUint(uint64(f))
n.Valid = true
}
case float32, float64:
if f, err := strconv.ParseFloat(s, 64); err == nil {
reflect.ValueOf(&n.Val).Elem().SetFloat(f)
n.Valid = true
}
case bool:
if b, err := strconv.ParseBool(s); err == nil {
n.Val = any(b).(T)
n.Valid = true
}
case time.Time:
if t, err := tryParseDT(s); err == nil && !t.IsZero() {
n.Val = any(t).(T)
n.Valid = true
}
case uuid.UUID:
if u, err := uuid.Parse(s); err == nil {
n.Val = any(u).(T)
n.Valid = true
}
case []byte:
n.Val = any([]byte(s)).(T)
n.Valid = true
case string:
n.Val = any(s).(T)
n.Valid = true
}
return nil
}
// Value implements driver.Valuer.
func (n SqlNull[T]) Value() (driver.Value, error) {
if !n.Valid {
return nil, nil
}
// Check if the type implements fmt.Stringer (e.g., uuid.UUID, custom types)
// Convert to string for driver compatibility
if stringer, ok := any(n.Val).(fmt.Stringer); ok {
return stringer.String(), nil
}
return any(n.Val), nil
}
// MarshalJSON implements json.Marshaler.
func (n SqlNull[T]) MarshalJSON() ([]byte, error) {
if !n.Valid {
return []byte("null"), nil
}
// Check if T is []byte, and encode to base64
if _, ok := any(n.Val).([]byte); ok {
// Encode []byte as base64
encoded := base64.StdEncoding.EncodeToString(any(n.Val).([]byte))
return json.Marshal(encoded)
}
return json.Marshal(n.Val)
}
// UnmarshalJSON implements json.Unmarshaler.
func (n *SqlNull[T]) UnmarshalJSON(b []byte) error {
if len(b) == 0 || string(b) == "null" || strings.TrimSpace(string(b)) == "" {
n.Valid = false
n.Val = *new(T)
return nil
}
// Check if T is []byte, and decode from base64
var val T
if _, ok := any(val).([]byte); ok {
// Unmarshal as string first (JSON representation)
var s string
if err := json.Unmarshal(b, &s); err == nil {
// Decode from base64
if decoded, err := base64.StdEncoding.DecodeString(s); err == nil {
n.Val = any(decoded).(T)
n.Valid = true
return nil
}
// Fallback to raw string as bytes
n.Val = any([]byte(s)).(T)
n.Valid = true
return nil
}
}
if err := json.Unmarshal(b, &val); err == nil {
n.Val = val
n.Valid = true
return nil
}
// Fallback: unmarshal as string and parse.
var s string
if err := json.Unmarshal(b, &s); err == nil {
return n.FromString(s)
}
return fmt.Errorf("cannot unmarshal %s into SqlNull[%T]", b, n.Val)
}
// MarshalYAML implements yaml.Marshaler.
func (n SqlNull[T]) MarshalYAML() (any, error) {
if !n.Valid {
return nil, nil
}
// Check if T is []byte, and encode to base64 (mirrors MarshalJSON).
if b, ok := any(n.Val).([]byte); ok {
return base64.StdEncoding.EncodeToString(b), nil
}
return n.Val, nil
}
// UnmarshalYAML implements yaml.Unmarshaler.
func (n *SqlNull[T]) UnmarshalYAML(value *yaml.Node) error {
if value == nil || value.Tag == "!!null" {
n.Valid = false
n.Val = *new(T)
return nil
}
// Check if T is []byte, and decode from base64.
var zero T
if _, ok := any(zero).([]byte); ok {
var s string
if err := value.Decode(&s); err == nil {
if decoded, err := base64.StdEncoding.DecodeString(s); err == nil {
n.Val = any(decoded).(T)
n.Valid = true
return nil
}
n.Val = any([]byte(s)).(T)
n.Valid = true
return nil
}
}
var val T
if err := value.Decode(&val); err == nil {
n.Val = val
n.Valid = true
return nil
}
// Fallback: decode as string and parse.
var s string
if err := value.Decode(&s); err == nil {
return n.FromString(s)
}
return fmt.Errorf("cannot unmarshal %q into SqlNull[%T]", value.Value, n.Val)
}
// MarshalXML implements xml.Marshaler.
func (n SqlNull[T]) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if !n.Valid {
return e.EncodeElement("", start)
}
// Check if T is []byte, and encode to base64 (mirrors MarshalJSON).
if b, ok := any(n.Val).([]byte); ok {
return e.EncodeElement(base64.StdEncoding.EncodeToString(b), start)
}
return e.EncodeElement(n.Val, start)
}
// UnmarshalXML implements xml.Unmarshaler.
//
// XML has no native null representation, so an empty element unmarshals to
// an invalid (null) value rather than a zero-value-but-valid one.
func (n *SqlNull[T]) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var s string
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
if s == "" {
n.Valid = false
n.Val = *new(T)
return nil
}
var zero T
if _, ok := any(zero).([]byte); ok {
if decoded, err := base64.StdEncoding.DecodeString(s); err == nil {
n.Val = any(decoded).(T)
n.Valid = true
return nil
}
n.Val = any([]byte(s)).(T)
n.Valid = true
return nil
}
return n.FromString(s)
}
// String implements fmt.Stringer.
func (n SqlNull[T]) String() string {
if !n.Valid {
return ""
}
// Check if the type implements fmt.Stringer for better string representation
if stringer, ok := any(n.Val).(fmt.Stringer); ok {
return stringer.String()
}
return fmt.Sprintf("%v", n.Val)
}
// Int64 converts to int64 or 0 if invalid.
func (n SqlNull[T]) Int64() int64 {
if !n.Valid {
return 0
}
v := reflect.ValueOf(any(n.Val))
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int()
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return int64(v.Uint())
case reflect.Float32, reflect.Float64:
return int64(v.Float())
case reflect.String:
i, _ := strconv.ParseInt(v.String(), 10, 64)
return i
case reflect.Bool:
if v.Bool() {
return 1
}
return 0
}
return 0
}
// Float64 converts to float64 or 0.0 if invalid.
func (n SqlNull[T]) Float64() float64 {
if !n.Valid {
return 0.0
}
v := reflect.ValueOf(any(n.Val))
switch v.Kind() {
case reflect.Float32, reflect.Float64:
return v.Float()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return float64(v.Int())
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return float64(v.Uint())
case reflect.String:
f, _ := strconv.ParseFloat(v.String(), 64)
return f
}
return 0.0
}
// Bool converts to bool or false if invalid.
func (n SqlNull[T]) Bool() bool {
if !n.Valid {
return false
}
v := reflect.ValueOf(any(n.Val))
if v.Kind() == reflect.Bool {
return v.Bool()
}
s := strings.ToLower(strings.TrimSpace(fmt.Sprint(n.Val)))
return s == "true" || s == "t" || s == "1" || s == "yes" || s == "on"
}
// Time converts to time.Time or zero if invalid.
func (n SqlNull[T]) Time() time.Time {
if !n.Valid {
return time.Time{}
}
if t, ok := any(n.Val).(time.Time); ok {
return t
}
return time.Time{}
}
// UUID converts to uuid.UUID or Nil if invalid.
func (n SqlNull[T]) UUID() uuid.UUID {
if !n.Valid {
return uuid.Nil
}
if u, ok := any(n.Val).(uuid.UUID); ok {
return u
}
return uuid.Nil
}
// Type aliases for common types.
type (
SqlInt16 = SqlNull[int16]
SqlInt32 = SqlNull[int32]
SqlInt64 = SqlNull[int64]
SqlFloat32 = SqlNull[float32]
SqlFloat64 = SqlNull[float64]
SqlBool = SqlNull[bool]
SqlString = SqlNull[string]
SqlByteArray = SqlNull[[]byte]
SqlUUID = SqlNull[uuid.UUID]
)
// SqlTimeStamp - Timestamp with custom formatting (YYYY-MM-DDTHH:MM:SS).
type SqlTimeStamp struct{ SqlNull[time.Time] }
func (t SqlTimeStamp) MarshalJSON() ([]byte, error) {
if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) {
return []byte("null"), nil
}
return fmt.Appendf(nil, `"%s"`, t.Val.Format("2006-01-02T15:04:05")), nil
}
func (t *SqlTimeStamp) UnmarshalJSON(b []byte) error {
if err := t.SqlNull.UnmarshalJSON(b); err != nil {
return err
}
if t.Valid && (t.Val.IsZero() || t.Val.Format("2006-01-02T15:04:05") == "0001-01-01T00:00:00") {
t.Valid = false
}
return nil
}
func (t SqlTimeStamp) Value() (driver.Value, error) {
if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) {
return nil, nil
}
return t.Val.Format("2006-01-02T15:04:05"), nil
}
func (t SqlTimeStamp) MarshalYAML() (any, error) {
if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) {
return nil, nil
}
return t.Val.Format("2006-01-02T15:04:05"), nil
}
func (t *SqlTimeStamp) UnmarshalYAML(value *yaml.Node) error {
if err := t.SqlNull.UnmarshalYAML(value); err != nil {
return err
}
if t.Valid && (t.Val.IsZero() || t.Val.Format("2006-01-02T15:04:05") == "0001-01-01T00:00:00") {
t.Valid = false
}
return nil
}
func (t SqlTimeStamp) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) {
return e.EncodeElement("", start)
}
return e.EncodeElement(t.Val.Format("2006-01-02T15:04:05"), start)
}
func (t *SqlTimeStamp) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var s string
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
if s == "" {
t.Valid = false
t.Val = time.Time{}
return nil
}
tm, err := tryParseDT(s)
if err != nil {
return err
}
t.Val = tm
t.Valid = !tm.IsZero() && tm.Format("2006-01-02T15:04:05") != "0001-01-01T00:00:00"
return nil
}
func SqlTimeStampNow() SqlTimeStamp {
return SqlTimeStamp{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
}
// SqlDate - Date only (YYYY-MM-DD).
type SqlDate struct{ SqlNull[time.Time] }
func (d SqlDate) MarshalJSON() ([]byte, error) {
if !d.Valid || d.Val.IsZero() {
return []byte("null"), nil
}
s := d.Val.Format("2006-01-02")
if strings.HasPrefix(s, "0001-01-01") {
return []byte("null"), nil
}
return fmt.Appendf(nil, `"%s"`, s), nil
}
func (d *SqlDate) UnmarshalJSON(b []byte) error {
if err := d.SqlNull.UnmarshalJSON(b); err != nil {
return err
}
if d.Valid && d.Val.Format("2006-01-02") <= "0001-01-01" {
d.Valid = false
}
return nil
}
func (d SqlDate) Value() (driver.Value, error) {
if !d.Valid || d.Val.IsZero() {
return nil, nil
}
s := d.Val.Format("2006-01-02")
if s <= "0001-01-01" {
return nil, nil
}
return s, nil
}
func (d SqlDate) String() string {
if !d.Valid {
return ""
}
s := d.Val.Format("2006-01-02")
if strings.HasPrefix(s, "0001-01-01") || strings.HasPrefix(s, "1800-12-31") {
return ""
}
return s
}
func (d SqlDate) MarshalYAML() (any, error) {
if !d.Valid || d.Val.IsZero() {
return nil, nil
}
s := d.Val.Format("2006-01-02")
if strings.HasPrefix(s, "0001-01-01") {
return nil, nil
}
return s, nil
}
func (d *SqlDate) UnmarshalYAML(value *yaml.Node) error {
if err := d.SqlNull.UnmarshalYAML(value); err != nil {
return err
}
if d.Valid && d.Val.Format("2006-01-02") <= "0001-01-01" {
d.Valid = false
}
return nil
}
func (d SqlDate) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if !d.Valid || d.Val.IsZero() {
return e.EncodeElement("", start)
}
s := d.Val.Format("2006-01-02")
if strings.HasPrefix(s, "0001-01-01") {
return e.EncodeElement("", start)
}
return e.EncodeElement(s, start)
}
func (d *SqlDate) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error {
var s string
if err := dec.DecodeElement(&s, &start); err != nil {
return err
}
if s == "" {
d.Valid = false
d.Val = time.Time{}
return nil
}
tm, err := tryParseDT(s)
if err != nil {
return err
}
d.Val = tm
d.Valid = !tm.IsZero() && tm.Format("2006-01-02") > "0001-01-01"
return nil
}
func SqlDateNow() SqlDate {
return SqlDate{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
}
// SqlTime - Time only (HH:MM:SS).
type SqlTime struct{ SqlNull[time.Time] }
func (t SqlTime) MarshalJSON() ([]byte, error) {
if !t.Valid || t.Val.IsZero() {
return []byte("null"), nil
}
s := t.Val.Format("15:04:05")
if s == "00:00:00" {
return []byte("null"), nil
}
return fmt.Appendf(nil, `"%s"`, s), nil
}
func (t *SqlTime) UnmarshalJSON(b []byte) error {
if err := t.SqlNull.UnmarshalJSON(b); err != nil {
return err
}
if t.Valid && t.Val.Format("15:04:05") == "00:00:00" {
t.Valid = false
}
return nil
}
func (t SqlTime) Value() (driver.Value, error) {
if !t.Valid || t.Val.IsZero() {
return nil, nil
}
return t.Val.Format("15:04:05"), nil
}
func (t SqlTime) String() string {
if !t.Valid {
return ""
}
return t.Val.Format("15:04:05")
}
func (t SqlTime) MarshalYAML() (any, error) {
if !t.Valid || t.Val.IsZero() {
return nil, nil
}
s := t.Val.Format("15:04:05")
if s == "00:00:00" {
return nil, nil
}
return s, nil
}
func (t *SqlTime) UnmarshalYAML(value *yaml.Node) error {
if err := t.SqlNull.UnmarshalYAML(value); err != nil {
return err
}
if t.Valid && t.Val.Format("15:04:05") == "00:00:00" {
t.Valid = false
}
return nil
}
func (t SqlTime) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if !t.Valid || t.Val.IsZero() {
return e.EncodeElement("", start)
}
s := t.Val.Format("15:04:05")
if s == "00:00:00" {
return e.EncodeElement("", start)
}
return e.EncodeElement(s, start)
}
func (t *SqlTime) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var s string
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
if s == "" {
t.Valid = false
t.Val = time.Time{}
return nil
}
tm, err := tryParseDT(s)
if err != nil {
return err
}
t.Val = tm
t.Valid = !tm.IsZero() && tm.Format("15:04:05") != "00:00:00"
return nil
}
func SqlTimeNow() SqlTime {
return SqlTime{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
}
// SqlJSONB - Nullable JSONB as []byte.
type SqlJSONB []byte
// SqlJSON - Nullable JSON as []byte. PostgreSQL's json and jsonb types share
// the same textual representation and Go marshalling behavior, differing only
// in server-side storage, so SqlJSON is an alias of SqlJSONB.
type SqlJSON = SqlJSONB
// Scan implements sql.Scanner.
func (n *SqlJSONB) Scan(value any) error {
if value == nil {
*n = nil
return nil
}
switch v := value.(type) {
case string:
*n = []byte(v)
case []byte:
*n = v
default:
dat, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshal value to JSON: %v", err)
}
*n = dat
}
return nil
}
// Value implements driver.Valuer.
func (n SqlJSONB) Value() (driver.Value, error) {
if len(n) == 0 {
return nil, nil
}
var js any
if err := json.Unmarshal(n, &js); err != nil {
return nil, fmt.Errorf("invalid JSON: %v", err)
}
return string(n), nil
}
// MarshalJSON implements json.Marshaler.
func (n SqlJSONB) MarshalJSON() ([]byte, error) {
if len(n) == 0 {
return []byte("null"), nil
}
var obj any
if err := json.Unmarshal(n, &obj); err != nil {
return []byte("null"), nil
}
return n, nil
}
// UnmarshalJSON implements json.Unmarshaler.
func (n *SqlJSONB) UnmarshalJSON(b []byte) error {
s := strings.TrimSpace(string(b))
if s == "null" || s == "" || (!strings.HasPrefix(s, "{") && !strings.HasPrefix(s, "[")) {
*n = nil
return nil
}
*n = b
return nil
}
// MarshalYAML implements yaml.Marshaler. The underlying JSON is decoded into
// a generic value first so it renders as native YAML mappings/sequences
// rather than an embedded JSON string.
func (n SqlJSONB) MarshalYAML() (any, error) {
if len(n) == 0 {
return nil, nil
}
var v any
if err := json.Unmarshal(n, &v); err != nil {
return nil, nil
}
return v, nil
}
// UnmarshalYAML implements yaml.Unmarshaler.
func (n *SqlJSONB) UnmarshalYAML(value *yaml.Node) error {
if value == nil || value.Tag == "!!null" {
*n = nil
return nil
}
var v any
if err := value.Decode(&v); err != nil {
return err
}
b, err := json.Marshal(v)
if err != nil {
return err
}
*n = b
return nil
}
// MarshalXML implements xml.Marshaler. JSON has no clean structural mapping
// to XML, so the raw JSON text is emitted as the element's text content.
func (n SqlJSONB) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if len(n) == 0 {
return e.EncodeElement("", start)
}
var obj any
if err := json.Unmarshal(n, &obj); err != nil {
return e.EncodeElement("", start)
}
return e.EncodeElement(string(n), start)
}
// UnmarshalXML implements xml.Unmarshaler, reading back the raw JSON text
// written by MarshalXML.
func (n *SqlJSONB) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var s string
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
s = strings.TrimSpace(s)
if s == "" {
*n = nil
return nil
}
*n = []byte(s)
return nil
}
func (n SqlJSONB) AsMap() (map[string]any, error) {
if len(n) == 0 {
return nil, nil
}
js := make(map[string]any)
if err := json.Unmarshal(n, &js); err != nil {
return nil, fmt.Errorf("invalid JSON: %v", err)
}
return js, nil
}
func (n SqlJSONB) AsSlice() ([]any, error) {
if len(n) == 0 {
return nil, nil
}
js := make([]any, 0)
if err := json.Unmarshal(n, &js); err != nil {
return nil, fmt.Errorf("invalid JSON: %v", err)
}
return js, nil
}
// TryIfInt64 tries to parse any value to int64 with default.
func TryIfInt64(v any, def int64) int64 {
switch val := v.(type) {
case string:
i, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return def
}
return i
case int:
return int64(val)
case int8:
return int64(val)
case int16:
return int64(val)
case int32:
return int64(val)
case int64:
return val
case uint:
return int64(val)
case uint8:
return int64(val)
case uint16:
return int64(val)
case uint32:
return int64(val)
case uint64:
return int64(val)
case float32:
return int64(val)
case float64:
return int64(val)
case []byte:
i, err := strconv.ParseInt(string(val), 10, 64)
if err != nil {
return def
}
return i
default:
return def
}
}
// Constructor helpers - clean and fast value creation
func Null[T any](v T, valid bool) SqlNull[T] {
return SqlNull[T]{Val: v, Valid: valid}
}
func NewSql[T any](value any) SqlNull[T] {
n := SqlNull[T]{}
if value == nil {
return n
}
// Fast path: exact match
if v, ok := value.(T); ok {
n.Val = v
n.Valid = true
return n
}
// Try from another SqlNull
if sn, ok := value.(SqlNull[T]); ok {
return sn
}
// Convert via string
_ = n.FromString(fmt.Sprintf("%v", value))
return n
}
func NewSqlInt16(v int16) SqlInt16 {
return SqlInt16{Val: v, Valid: true}
}
func NewSqlInt32(v int32) SqlInt32 {
return SqlInt32{Val: v, Valid: true}
}
func NewSqlInt64(v int64) SqlInt64 {
return SqlInt64{Val: v, Valid: true}
}
func NewSqlFloat32(v float32) SqlFloat32 {
return SqlFloat32{Val: v, Valid: true}
}
func NewSqlFloat64(v float64) SqlFloat64 {
return SqlFloat64{Val: v, Valid: true}
}
func NewSqlBool(v bool) SqlBool {
return SqlBool{Val: v, Valid: true}
}
func NewSqlString(v string) SqlString {
return SqlString{Val: v, Valid: true}
}
func NewSqlByteArray(v []byte) SqlByteArray {
return SqlByteArray{Val: v, Valid: true}
}
func NewSqlUUID(v uuid.UUID) SqlUUID {
return SqlUUID{Val: v, Valid: true}
}
func NewSqlTimeStamp(v time.Time) SqlTimeStamp {
return SqlTimeStamp{SqlNull: SqlNull[time.Time]{Val: v, Valid: true}}
}
func NewSqlDate(v time.Time) SqlDate {
return SqlDate{SqlNull: SqlNull[time.Time]{Val: v, Valid: true}}
}
func NewSqlTime(v time.Time) SqlTime {
return SqlTime{SqlNull: SqlNull[time.Time]{Val: v, Valid: true}}
}
+134
View File
@@ -0,0 +1,134 @@
package sqltypes
import (
"encoding/json"
"testing"
)
// TestSqlNull_FromString_UnsignedInt guards against a regression where
// FromString called reflect.Value.SetInt on unsigned-kind fields, which
// panics since SetInt only accepts Int/Int8/.../Int64 kinds.
func TestSqlNull_FromString_UnsignedInt(t *testing.T) {
tests := []struct {
name string
input string
expected uint32
}{
{"simple", "123", 123},
{"zero", "0", 0},
{"large", "4000000000", 4000000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var n SqlNull[uint32]
if err := n.FromString(tt.input); err != nil {
t.Fatalf("FromString failed: %v", err)
}
if !n.Valid {
t.Fatalf("expected valid=true")
}
if n.Val != tt.expected {
t.Errorf("expected %d, got %d", tt.expected, n.Val)
}
})
}
}
// TestSqlNull_FromString_Int64Precision guards against a regression where
// FromString unconditionally re-parsed the string as float64 after a
// successful ParseInt, corrupting large int64 values due to float rounding
// (e.g. math.MaxInt64 became negative).
func TestSqlNull_FromString_Int64Precision(t *testing.T) {
tests := []struct {
name string
input string
expected int64
}{
{"max int64", "9223372036854775807", 9223372036854775807},
{"min int64", "-9223372036854775808", -9223372036854775808},
{"large safe value", "1234567890123456", 1234567890123456},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var n SqlNull[int64]
if err := n.FromString(tt.input); err != nil {
t.Fatalf("FromString failed: %v", err)
}
if !n.Valid {
t.Fatalf("expected valid=true")
}
if n.Val != tt.expected {
t.Errorf("expected %d, got %d", tt.expected, n.Val)
}
})
}
}
// TestSqlNull_FromString_FloatFallback verifies that non-integral strings
// still parse via the float fallback path for integer-kind fields.
func TestSqlNull_FromString_FloatFallback(t *testing.T) {
var n SqlNull[int32]
if err := n.FromString("42.9"); err != nil {
t.Fatalf("FromString failed: %v", err)
}
if !n.Valid || n.Val != 42 {
t.Errorf("expected valid int32=42, got valid=%v val=%d", n.Valid, n.Val)
}
var u SqlNull[uint32]
if err := u.FromString("42.9"); err != nil {
t.Fatalf("FromString failed: %v", err)
}
if !u.Valid || u.Val != 42 {
t.Errorf("expected valid uint32=42, got valid=%v val=%d", u.Valid, u.Val)
}
var neg SqlNull[uint32]
if err := neg.FromString("-1.5"); err != nil {
t.Fatalf("FromString failed: %v", err)
}
if neg.Valid {
t.Errorf("expected invalid for negative value into unsigned type, got %v", neg.Val)
}
}
// TestSqlNull_UnsignedTypes_ScanAndJSON exercises Scan and JSON round-trip
// for every unsigned alias to make sure none of them panic.
func TestSqlNull_UnsignedTypes_ScanAndJSON(t *testing.T) {
t.Run("uint8 scan from string", func(t *testing.T) {
var n SqlNull[uint8]
if err := n.Scan("200"); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if !n.Valid || n.Val != 200 {
t.Errorf("expected valid uint8=200, got valid=%v val=%d", n.Valid, n.Val)
}
})
t.Run("uint64 json round-trip", func(t *testing.T) {
n := Null(uint64(18446744073709551615), true)
data, err := json.Marshal(n)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var n2 SqlNull[uint64]
if err := json.Unmarshal(data, &n2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if n2.Val != n.Val {
t.Errorf("expected %d, got %d", n.Val, n2.Val)
}
})
t.Run("uint scan from numeric string fallback", func(t *testing.T) {
var n SqlNull[uint]
if err := n.Scan([]byte("77")); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if !n.Valid || n.Val != 77 {
t.Errorf("expected valid uint=77, got valid=%v val=%d", n.Valid, n.Val)
}
})
}
+958
View File
@@ -0,0 +1,958 @@
package sqltypes
import (
"database/sql/driver"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
)
// TestNewSqlInt16 tests NewSqlInt16 type
func TestNewSqlInt16(t *testing.T) {
tests := []struct {
name string
input interface{}
expected SqlInt16
}{
{"int", 42, Null(int16(42), true)},
{"int32", int32(100), NewSqlInt16(100)},
{"int64", int64(200), NewSqlInt16(200)},
{"string", "123", NewSqlInt16(123)},
{"nil", nil, Null(int16(0), false)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var n SqlInt16
if err := n.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if n != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, n)
}
})
}
}
func TestNewSqlInt16_Value(t *testing.T) {
tests := []struct {
name string
input SqlInt16
expected driver.Value
}{
{"zero", Null(int16(0), false), nil},
{"positive", NewSqlInt16(42), int16(42)},
{"negative", NewSqlInt16(-10), int16(-10)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
val, err := tt.input.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
if val != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, val)
}
})
}
}
func TestNewSqlInt16_JSON(t *testing.T) {
n := NewSqlInt16(42)
// Marshal
data, err := json.Marshal(n)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
expected := "42"
if string(data) != expected {
t.Errorf("expected %s, got %s", expected, string(data))
}
// Unmarshal
var n2 SqlInt16
if err := json.Unmarshal([]byte("123"), &n2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if n2.Int64() != 123 {
t.Errorf("expected 123, got %d", n2.Int64())
}
}
// TestNewSqlInt64 tests NewSqlInt64 type
func TestNewSqlInt64(t *testing.T) {
tests := []struct {
name string
input interface{}
expected SqlInt64
}{
{"int", 42, NewSqlInt64(42)},
{"int32", int32(100), NewSqlInt64(100)},
{"int64", int64(9223372036854775807), NewSqlInt64(9223372036854775807)},
{"uint32", uint32(100), NewSqlInt64(100)},
{"uint64", uint64(200), NewSqlInt64(200)},
{"nil", nil, SqlInt64{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var n SqlInt64
if err := n.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if n != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, n)
}
})
}
}
// TestSqlFloat64 tests SqlFloat64 type
func TestSqlFloat64(t *testing.T) {
tests := []struct {
name string
input interface{}
expected float64
valid bool
}{
{"float64", float64(3.14), 3.14, true},
{"float32", float32(2.5), 2.5, true},
{"int", 42, 42.0, true},
{"int64", int64(100), 100.0, true},
{"nil", nil, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var n SqlFloat64
if err := n.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if n.Valid != tt.valid {
t.Errorf("expected valid=%v, got valid=%v", tt.valid, n.Valid)
}
if tt.valid && n.Float64() != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, n.Float64())
}
})
}
}
// TestSqlTimeStamp tests SqlTimeStamp type
func TestSqlTimeStamp(t *testing.T) {
now := time.Now()
tests := []struct {
name string
input interface{}
}{
{"time.Time", now},
{"string RFC3339", now.Format(time.RFC3339)},
{"string date", "2024-01-15"},
{"string datetime", "2024-01-15T10:30:00"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var ts SqlTimeStamp
if err := ts.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if ts.Time().IsZero() {
t.Error("expected non-zero time")
}
})
}
}
func TestSqlTimeStamp_JSON(t *testing.T) {
now := time.Date(2024, 1, 15, 10, 30, 45, 0, time.UTC)
ts := NewSqlTimeStamp(now)
// Marshal
data, err := json.Marshal(ts)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
expected := `"2024-01-15T10:30:45"`
if string(data) != expected {
t.Errorf("expected %s, got %s", expected, string(data))
}
// Unmarshal
var ts2 SqlTimeStamp
if err := json.Unmarshal([]byte(`"2024-01-15T10:30:45"`), &ts2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if ts2.Time().Year() != 2024 {
t.Errorf("expected year 2024, got %d", ts2.Time().Year())
}
// Test null
var ts3 SqlTimeStamp
if err := json.Unmarshal([]byte("null"), &ts3); err != nil {
t.Fatalf("Unmarshal null failed: %v", err)
}
}
// TestSqlDate tests SqlDate type
func TestSqlDate(t *testing.T) {
now := time.Now()
tests := []struct {
name string
input interface{}
}{
{"time.Time", now},
{"string date", "2024-01-15"},
{"string UK format", "15/01/2024"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var d SqlDate
if err := d.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if d.String() == "0" {
t.Error("expected non-zero date")
}
})
}
}
func TestSqlDate_JSON(t *testing.T) {
date := NewSqlDate(time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC))
// Marshal
data, err := json.Marshal(date)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
expected := `"2024-01-15"`
if string(data) != expected {
t.Errorf("expected %s, got %s", expected, string(data))
}
// Unmarshal
var d2 SqlDate
if err := json.Unmarshal([]byte(`"2024-01-15"`), &d2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
}
// TestSqlTime tests SqlTime type
func TestSqlTime(t *testing.T) {
now := time.Now()
tests := []struct {
name string
input interface{}
expected string
}{
{"time.Time", now, now.Format("15:04:05")},
{"string time", "10:30:45", "10:30:45"},
{"string short time", "10:30", "10:30:00"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var tm SqlTime
if err := tm.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if tm.String() != tt.expected {
t.Errorf("expected %s, got %s", tt.expected, tm.String())
}
})
}
}
// TestSqlJSONB tests SqlJSONB type
func TestSqlJSONB_Scan(t *testing.T) {
tests := []struct {
name string
input interface{}
expected string
}{
{"string JSON object", `{"key":"value"}`, `{"key":"value"}`},
{"string JSON array", `[1,2,3]`, `[1,2,3]`},
{"bytes", []byte(`{"test":true}`), `{"test":true}`},
{"nil", nil, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var j SqlJSONB
if err := j.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if tt.expected == "" && j == nil {
return // nil case
}
if string(j) != tt.expected {
t.Errorf("expected %s, got %s", tt.expected, string(j))
}
})
}
}
func TestSqlJSONB_Value(t *testing.T) {
tests := []struct {
name string
input SqlJSONB
expected string
wantErr bool
}{
{"valid object", SqlJSONB(`{"key":"value"}`), `{"key":"value"}`, false},
{"valid array", SqlJSONB(`[1,2,3]`), `[1,2,3]`, false},
{"empty", SqlJSONB{}, "", false},
{"nil", nil, "", false},
{"invalid JSON", SqlJSONB(`{invalid`), "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
val, err := tt.input.Value()
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("Value failed: %v", err)
}
if tt.expected == "" && val == nil {
return // nil case
}
if val.(string) != tt.expected {
t.Errorf("expected %s, got %s", tt.expected, val)
}
})
}
}
func TestSqlJSONB_JSON(t *testing.T) {
// Marshal
j := SqlJSONB(`{"name":"test","count":42}`)
data, err := json.Marshal(j)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("Unmarshal result failed: %v", err)
}
if result["name"] != "test" {
t.Errorf("expected name=test, got %v", result["name"])
}
// Unmarshal
var j2 SqlJSONB
if err := json.Unmarshal([]byte(`{"key":"value"}`), &j2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if string(j2) != `{"key":"value"}` {
t.Errorf("expected {\"key\":\"value\"}, got %s", string(j2))
}
// Test null
var j3 SqlJSONB
if err := json.Unmarshal([]byte("null"), &j3); err != nil {
t.Fatalf("Unmarshal null failed: %v", err)
}
}
func TestSqlJSONB_AsMap(t *testing.T) {
tests := []struct {
name string
input SqlJSONB
wantErr bool
wantNil bool
}{
{"valid object", SqlJSONB(`{"name":"test","age":30}`), false, false},
{"empty", SqlJSONB{}, false, true},
{"nil", nil, false, true},
{"invalid JSON", SqlJSONB(`{invalid`), true, false},
{"array not object", SqlJSONB(`[1,2,3]`), true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m, err := tt.input.AsMap()
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("AsMap failed: %v", err)
}
if tt.wantNil {
if m != nil {
t.Errorf("expected nil, got %v", m)
}
return
}
if m == nil {
t.Error("expected non-nil map")
}
})
}
}
func TestSqlJSONB_AsSlice(t *testing.T) {
tests := []struct {
name string
input SqlJSONB
wantErr bool
wantNil bool
}{
{"valid array", SqlJSONB(`[1,2,3]`), false, false},
{"empty", SqlJSONB{}, false, true},
{"nil", nil, false, true},
{"invalid JSON", SqlJSONB(`[invalid`), true, false},
{"object not array", SqlJSONB(`{"key":"value"}`), true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s, err := tt.input.AsSlice()
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("AsSlice failed: %v", err)
}
if tt.wantNil {
if s != nil {
t.Errorf("expected nil, got %v", s)
}
return
}
if s == nil {
t.Error("expected non-nil slice")
}
})
}
}
// TestSqlUUID tests SqlUUID type
func TestSqlUUID_Scan(t *testing.T) {
testUUID := uuid.New()
testUUIDStr := testUUID.String()
tests := []struct {
name string
input interface{}
expected string
valid bool
}{
{"string UUID", testUUIDStr, testUUIDStr, true},
{"bytes UUID", []byte(testUUIDStr), testUUIDStr, true},
{"nil", nil, "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var u SqlUUID
if err := u.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if u.Valid != tt.valid {
t.Errorf("expected valid=%v, got valid=%v", tt.valid, u.Valid)
}
if tt.valid && u.String() != tt.expected {
t.Errorf("expected %s, got %s", tt.expected, u.String())
}
})
}
}
func TestSqlUUID_Value(t *testing.T) {
testUUID := uuid.New()
u := NewSqlUUID(testUUID)
val, err := u.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
// Value() should return a string for driver compatibility
if val != testUUID.String() {
t.Errorf("expected %s, got %s", testUUID.String(), val)
}
// Test invalid UUID
u2 := SqlUUID{Valid: false}
val2, err := u2.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
if val2 != nil {
t.Errorf("expected nil, got %v", val2)
}
}
func TestSqlUUID_JSON(t *testing.T) {
testUUID := uuid.New()
u := NewSqlUUID(testUUID)
// Marshal
data, err := json.Marshal(u)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
expected := `"` + testUUID.String() + `"`
if string(data) != expected {
t.Errorf("expected %s, got %s", expected, string(data))
}
// Unmarshal
var u2 SqlUUID
if err := json.Unmarshal([]byte(`"`+testUUID.String()+`"`), &u2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if u2.String() != testUUID.String() {
t.Errorf("expected %s, got %s", testUUID.String(), u2.String())
}
// Test null
var u3 SqlUUID
if err := json.Unmarshal([]byte("null"), &u3); err != nil {
t.Fatalf("Unmarshal null failed: %v", err)
}
if u3.Valid {
t.Error("expected invalid UUID")
}
}
// TestTryIfInt64 tests the TryIfInt64 helper function
func TestTryIfInt64(t *testing.T) {
tests := []struct {
name string
input interface{}
def int64
expected int64
}{
{"string valid", "123", 0, 123},
{"string invalid", "abc", 99, 99},
{"int", 42, 0, 42},
{"int32", int32(100), 0, 100},
{"int64", int64(200), 0, 200},
{"uint32", uint32(50), 0, 50},
{"uint64", uint64(75), 0, 75},
{"float32", float32(3.14), 0, 3},
{"float64", float64(2.71), 0, 2},
{"bytes", []byte("456"), 0, 456},
{"unknown type", struct{}{}, 999, 999},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := TryIfInt64(tt.input, tt.def)
if result != tt.expected {
t.Errorf("expected %d, got %d", tt.expected, result)
}
})
}
}
// TestSqlString tests SqlString without base64 (plain text)
func TestSqlString_Scan(t *testing.T) {
tests := []struct {
name string
input interface{}
expected string
valid bool
}{
{
name: "plain string",
input: "hello world",
expected: "hello world",
valid: true,
},
{
name: "plain text",
input: "plain text",
expected: "plain text",
valid: true,
},
{
name: "bytes as string",
input: []byte("raw bytes"),
expected: "raw bytes",
valid: true,
},
{
name: "nil value",
input: nil,
expected: "",
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s SqlString
if err := s.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if s.Valid != tt.valid {
t.Errorf("expected valid=%v, got valid=%v", tt.valid, s.Valid)
}
if tt.valid && s.String() != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, s.String())
}
})
}
}
func TestSqlString_JSON(t *testing.T) {
tests := []struct {
name string
inputValue string
expectedJSON string
expectedDecode string
}{
{
name: "simple string",
inputValue: "hello world",
expectedJSON: `"hello world"`, // plain text, not base64
expectedDecode: "hello world",
},
{
name: "special characters",
inputValue: "test@#$%",
expectedJSON: `"test@#$%"`, // plain text, not base64
expectedDecode: "test@#$%",
},
{
name: "unicode string",
inputValue: "Hello 世界",
expectedJSON: `"Hello 世界"`, // plain text, not base64
expectedDecode: "Hello 世界",
},
{
name: "empty string",
inputValue: "",
expectedJSON: `""`,
expectedDecode: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test MarshalJSON
s := NewSqlString(tt.inputValue)
data, err := json.Marshal(s)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != tt.expectedJSON {
t.Errorf("Marshal: expected %s, got %s", tt.expectedJSON, string(data))
}
// Test UnmarshalJSON
var s2 SqlString
if err := json.Unmarshal(data, &s2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if !s2.Valid {
t.Error("expected valid=true after unmarshal")
}
if s2.String() != tt.expectedDecode {
t.Errorf("Unmarshal: expected %q, got %q", tt.expectedDecode, s2.String())
}
})
}
}
func TestSqlString_JSON_Null(t *testing.T) {
// Test null handling
var s SqlString
if err := json.Unmarshal([]byte("null"), &s); err != nil {
t.Fatalf("Unmarshal null failed: %v", err)
}
if s.Valid {
t.Error("expected invalid after unmarshaling null")
}
// Test marshal null
data, err := json.Marshal(s)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != "null" {
t.Errorf("expected null, got %s", string(data))
}
}
// TestSqlByteArray_Base64 tests SqlByteArray with base64 encoding/decoding
func TestSqlByteArray_Base64_Scan(t *testing.T) {
tests := []struct {
name string
input interface{}
expected []byte
valid bool
}{
{
name: "base64 encoded bytes from SQL",
input: "aGVsbG8gd29ybGQ=", // "hello world" in base64
expected: []byte("hello world"),
valid: true,
},
{
name: "plain bytes fallback",
input: "plain text",
expected: []byte("plain text"),
valid: true,
},
{
name: "bytes base64 encoded",
input: []byte("SGVsbG8gR29waGVy"), // "Hello Gopher" in base64
expected: []byte("Hello Gopher"),
valid: true,
},
{
name: "bytes plain fallback",
input: []byte("raw bytes"),
expected: []byte("raw bytes"),
valid: true,
},
{
name: "binary data",
input: "AQIDBA==", // []byte{1, 2, 3, 4} in base64
expected: []byte{1, 2, 3, 4},
valid: true,
},
{
name: "nil value",
input: nil,
expected: nil,
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var b SqlByteArray
if err := b.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if b.Valid != tt.valid {
t.Errorf("expected valid=%v, got valid=%v", tt.valid, b.Valid)
}
if tt.valid {
if string(b.Val) != string(tt.expected) {
t.Errorf("expected %q, got %q", tt.expected, b.Val)
}
}
})
}
}
func TestSqlByteArray_Base64_JSON(t *testing.T) {
tests := []struct {
name string
inputValue []byte
expectedJSON string
expectedDecode []byte
}{
{
name: "text bytes",
inputValue: []byte("hello world"),
expectedJSON: `"aGVsbG8gd29ybGQ="`, // base64 encoded
expectedDecode: []byte("hello world"),
},
{
name: "binary data",
inputValue: []byte{0x01, 0x02, 0x03, 0x04, 0xFF},
expectedJSON: `"AQIDBP8="`, // base64 encoded
expectedDecode: []byte{0x01, 0x02, 0x03, 0x04, 0xFF},
},
{
name: "empty bytes",
inputValue: []byte{},
expectedJSON: `""`, // base64 of empty bytes
expectedDecode: []byte{},
},
{
name: "unicode bytes",
inputValue: []byte("Hello 世界"),
expectedJSON: `"SGVsbG8g5LiW55WM"`, // base64 encoded
expectedDecode: []byte("Hello 世界"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test MarshalJSON
b := NewSqlByteArray(tt.inputValue)
data, err := json.Marshal(b)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != tt.expectedJSON {
t.Errorf("Marshal: expected %s, got %s", tt.expectedJSON, string(data))
}
// Test UnmarshalJSON
var b2 SqlByteArray
if err := json.Unmarshal(data, &b2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if !b2.Valid {
t.Error("expected valid=true after unmarshal")
}
if string(b2.Val) != string(tt.expectedDecode) {
t.Errorf("Unmarshal: expected %v, got %v", tt.expectedDecode, b2.Val)
}
})
}
}
func TestSqlByteArray_Base64_JSON_Null(t *testing.T) {
// Test null handling
var b SqlByteArray
if err := json.Unmarshal([]byte("null"), &b); err != nil {
t.Fatalf("Unmarshal null failed: %v", err)
}
if b.Valid {
t.Error("expected invalid after unmarshaling null")
}
// Test marshal null
data, err := json.Marshal(b)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != "null" {
t.Errorf("expected null, got %s", string(data))
}
}
func TestSqlByteArray_Value(t *testing.T) {
tests := []struct {
name string
input SqlByteArray
expected interface{}
}{
{
name: "valid bytes",
input: NewSqlByteArray([]byte("test data")),
expected: []byte("test data"),
},
{
name: "empty bytes",
input: NewSqlByteArray([]byte{}),
expected: []byte{},
},
{
name: "invalid",
input: SqlByteArray{Valid: false},
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
val, err := tt.input.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
if tt.expected == nil && val != nil {
t.Errorf("expected nil, got %v", val)
}
if tt.expected != nil && val == nil {
t.Errorf("expected %v, got nil", tt.expected)
}
if tt.expected != nil && val != nil {
if string(val.([]byte)) != string(tt.expected.([]byte)) {
t.Errorf("expected %v, got %v", tt.expected, val)
}
}
})
}
}
// TestSqlString_RoundTrip tests complete round-trip: Go -> JSON -> Go -> SQL -> Go
func TestSqlString_RoundTrip(t *testing.T) {
original := "Test String with Special Chars: @#$%^&*()"
// Go -> JSON
s1 := NewSqlString(original)
jsonData, err := json.Marshal(s1)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
// JSON -> Go
var s2 SqlString
if err := json.Unmarshal(jsonData, &s2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
// Go -> SQL (Value)
_, err = s2.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
// SQL -> Go (Scan plain text)
var s3 SqlString
// Simulate SQL driver returning plain text value
if err := s3.Scan(original); err != nil {
t.Fatalf("Scan failed: %v", err)
}
// Verify round-trip
if s3.String() != original {
t.Errorf("Round-trip failed: expected %q, got %q", original, s3.String())
}
}
// TestSqlByteArray_Base64_RoundTrip tests complete round-trip: Go -> JSON -> Go -> SQL -> Go
func TestSqlByteArray_Base64_RoundTrip(t *testing.T) {
original := []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0xFF, 0xFE} // "Hello " + binary data
// Go -> JSON
b1 := NewSqlByteArray(original)
jsonData, err := json.Marshal(b1)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
// JSON -> Go
var b2 SqlByteArray
if err := json.Unmarshal(jsonData, &b2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
// Go -> SQL (Value)
_, err = b2.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
// SQL -> Go (Scan with base64)
var b3 SqlByteArray
// Simulate SQL driver returning base64 encoded value
if err := b3.Scan("SGVsbG8g//4="); err != nil {
t.Fatalf("Scan failed: %v", err)
}
// Verify round-trip
if string(b3.Val) != string(original) {
t.Errorf("Round-trip failed: expected %v, got %v", original, b3.Val)
}
}
+678
View File
@@ -0,0 +1,678 @@
package sqltypes
import (
"encoding/xml"
"testing"
"time"
"github.com/google/uuid"
"gopkg.in/yaml.v3"
)
// ── SqlNull: YAML ────────────────────────────────────────────────────────────
func TestSqlNull_YAML_Int(t *testing.T) {
n := NewSqlInt32(42)
data, err := yaml.Marshal(n)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != "42\n" {
t.Errorf("expected \"42\\n\", got %q", string(data))
}
var n2 SqlInt32
if err := yaml.Unmarshal(data, &n2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if n2.Int64() != 42 {
t.Errorf("expected 42, got %d", n2.Int64())
}
}
func TestSqlNull_YAML_Null(t *testing.T) {
var n SqlInt32
data, err := yaml.Marshal(n)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != "null\n" {
t.Errorf("expected \"null\\n\", got %q", string(data))
}
var n2 SqlInt32
if err := yaml.Unmarshal([]byte("null\n"), &n2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if n2.Valid {
t.Error("expected invalid after unmarshaling null")
}
// ~ is also a YAML null.
var n3 SqlInt32
if err := yaml.Unmarshal([]byte("~\n"), &n3); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if n3.Valid {
t.Error("expected invalid after unmarshaling ~")
}
}
func TestSqlNull_YAML_String(t *testing.T) {
s := NewSqlString("hello world")
data, err := yaml.Marshal(s)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var s2 SqlString
if err := yaml.Unmarshal(data, &s2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if s2.String() != "hello world" {
t.Errorf("expected %q, got %q", "hello world", s2.String())
}
}
func TestSqlNull_YAML_UUID(t *testing.T) {
id := uuid.New()
u := NewSqlUUID(id)
data, err := yaml.Marshal(u)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != id.String()+"\n" {
t.Errorf("expected %q, got %q", id.String()+"\n", string(data))
}
var u2 SqlUUID
if err := yaml.Unmarshal(data, &u2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if u2.UUID() != id {
t.Errorf("expected %v, got %v", id, u2.UUID())
}
}
func TestSqlNull_YAML_ByteArray_Base64(t *testing.T) {
orig := []byte{0x01, 0x02, 0xFF}
b := NewSqlByteArray(orig)
data, err := yaml.Marshal(b)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var b2 SqlByteArray
if err := yaml.Unmarshal(data, &b2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if string(b2.Val) != string(orig) {
t.Errorf("expected %v, got %v", orig, b2.Val)
}
}
// ── SqlNull: XML ─────────────────────────────────────────────────────────────
type xmlIntWrapper struct {
XMLName xml.Name `xml:"root"`
Value SqlInt32 `xml:"value"`
}
func TestSqlNull_XML_Int(t *testing.T) {
w := xmlIntWrapper{Value: NewSqlInt32(99)}
data, err := xml.Marshal(w)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var w2 xmlIntWrapper
if err := xml.Unmarshal(data, &w2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if w2.Value.Int64() != 99 {
t.Errorf("expected 99, got %d", w2.Value.Int64())
}
}
func TestSqlNull_XML_Null(t *testing.T) {
w := xmlIntWrapper{}
data, err := xml.Marshal(w)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var w2 xmlIntWrapper
if err := xml.Unmarshal(data, &w2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if w2.Value.Valid {
t.Errorf("expected invalid, got %v", w2.Value)
}
}
type xmlUUIDWrapper struct {
XMLName xml.Name `xml:"root"`
ID SqlUUID `xml:"id"`
}
func TestSqlNull_XML_UUID(t *testing.T) {
id := uuid.New()
w := xmlUUIDWrapper{ID: NewSqlUUID(id)}
data, err := xml.Marshal(w)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var w2 xmlUUIDWrapper
if err := xml.Unmarshal(data, &w2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if w2.ID.UUID() != id {
t.Errorf("expected %v, got %v", id, w2.ID.UUID())
}
}
type xmlByteWrapper struct {
XMLName xml.Name `xml:"root"`
Data SqlByteArray `xml:"data"`
}
func TestSqlNull_XML_ByteArray_Base64(t *testing.T) {
orig := []byte{0xDE, 0xAD, 0xBE, 0xEF}
w := xmlByteWrapper{Data: NewSqlByteArray(orig)}
data, err := xml.Marshal(w)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var w2 xmlByteWrapper
if err := xml.Unmarshal(data, &w2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if string(w2.Data.Val) != string(orig) {
t.Errorf("expected %v, got %v", orig, w2.Data.Val)
}
}
// ── SqlTimeStamp / SqlDate / SqlTime: YAML + XML ────────────────────────────
func TestSqlTimeStamp_YAML(t *testing.T) {
ts := NewSqlTimeStamp(time.Date(2024, 6, 15, 9, 30, 0, 0, time.UTC))
data, err := yaml.Marshal(ts)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != "2024-06-15T09:30:00\n" {
t.Errorf("unexpected YAML: %q", string(data))
}
var ts2 SqlTimeStamp
if err := yaml.Unmarshal(data, &ts2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if ts2.Time().Format("2006-01-02T15:04:05") != "2024-06-15T09:30:00" {
t.Errorf("expected 2024-06-15T09:30:00, got %v", ts2.Time())
}
var zero SqlTimeStamp
zdata, err := yaml.Marshal(zero)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(zdata) != "null\n" {
t.Errorf("expected null, got %q", zdata)
}
}
type xmlTimeStampWrapper struct {
XMLName xml.Name `xml:"root"`
At SqlTimeStamp `xml:"at"`
}
func TestSqlTimeStamp_XML(t *testing.T) {
w := xmlTimeStampWrapper{At: NewSqlTimeStamp(time.Date(2024, 6, 15, 9, 30, 0, 0, time.UTC))}
data, err := xml.Marshal(w)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var w2 xmlTimeStampWrapper
if err := xml.Unmarshal(data, &w2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if w2.At.Time().Format("2006-01-02T15:04:05") != "2024-06-15T09:30:00" {
t.Errorf("expected 2024-06-15T09:30:00, got %v", w2.At.Time())
}
}
func TestSqlDate_YAML(t *testing.T) {
d := NewSqlDate(time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC))
data, err := yaml.Marshal(d)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != "\"2024-06-15\"\n" {
t.Errorf("unexpected YAML: %q", string(data))
}
var d2 SqlDate
if err := yaml.Unmarshal(data, &d2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if d2.String() != "2024-06-15" {
t.Errorf("expected 2024-06-15, got %q", d2.String())
}
}
type xmlDateWrapper struct {
XMLName xml.Name `xml:"root"`
Day SqlDate `xml:"day"`
}
func TestSqlDate_XML(t *testing.T) {
w := xmlDateWrapper{Day: NewSqlDate(time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC))}
data, err := xml.Marshal(w)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var w2 xmlDateWrapper
if err := xml.Unmarshal(data, &w2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if w2.Day.String() != "2024-06-15" {
t.Errorf("expected 2024-06-15, got %q", w2.Day.String())
}
}
func TestSqlTime_YAML(t *testing.T) {
tm := NewSqlTime(time.Date(0, 1, 1, 14, 5, 9, 0, time.UTC))
data, err := yaml.Marshal(tm)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != "\"14:05:09\"\n" {
t.Errorf("unexpected YAML: %q", string(data))
}
var tm2 SqlTime
if err := yaml.Unmarshal(data, &tm2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if tm2.String() != "14:05:09" {
t.Errorf("expected 14:05:09, got %q", tm2.String())
}
}
type xmlTimeWrapper struct {
XMLName xml.Name `xml:"root"`
At SqlTime `xml:"at"`
}
func TestSqlTime_XML(t *testing.T) {
w := xmlTimeWrapper{At: NewSqlTime(time.Date(0, 1, 1, 14, 5, 9, 0, time.UTC))}
data, err := xml.Marshal(w)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var w2 xmlTimeWrapper
if err := xml.Unmarshal(data, &w2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if w2.At.String() != "14:05:09" {
t.Errorf("expected 14:05:09, got %q", w2.At.String())
}
}
// ── SqlJSONB: YAML + XML ─────────────────────────────────────────────────────
func TestSqlJSONB_YAML(t *testing.T) {
j := SqlJSONB(`{"name":"test","count":42}`)
data, err := yaml.Marshal(j)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var decoded map[string]any
if err := yaml.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed decoding produced YAML: %v", err)
}
if decoded["name"] != "test" {
t.Errorf("expected name=test, got %v", decoded["name"])
}
var j2 SqlJSONB
if err := yaml.Unmarshal(data, &j2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
m, err := j2.AsMap()
if err != nil {
t.Fatalf("AsMap failed: %v", err)
}
if m["name"] != "test" {
t.Errorf("expected name=test, got %v", m["name"])
}
}
func TestSqlJSONB_YAML_Null(t *testing.T) {
var j SqlJSONB
data, err := yaml.Marshal(j)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != "null\n" {
t.Errorf("expected null, got %q", data)
}
var j2 SqlJSONB
if err := yaml.Unmarshal([]byte("null\n"), &j2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if j2 != nil {
t.Errorf("expected nil, got %v", j2)
}
}
type xmlJSONBWrapper struct {
XMLName xml.Name `xml:"root"`
Meta SqlJSONB `xml:"meta"`
}
func TestSqlJSONB_XML(t *testing.T) {
w := xmlJSONBWrapper{Meta: SqlJSONB(`{"key":"value"}`)}
data, err := xml.Marshal(w)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var w2 xmlJSONBWrapper
if err := xml.Unmarshal(data, &w2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
m, err := w2.Meta.AsMap()
if err != nil {
t.Fatalf("AsMap failed: %v", err)
}
if m["key"] != "value" {
t.Errorf("expected key=value, got %v", m)
}
}
// ── Array types: YAML ────────────────────────────────────────────────────────
func TestSqlStringArray_YAML(t *testing.T) {
a := NewSqlStringArray([]string{"a", "b", "c"})
data, err := yaml.Marshal(a)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var a2 SqlStringArray
if err := yaml.Unmarshal(data, &a2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if len(a2.Val) != 3 || a2.Val[1] != "b" {
t.Errorf("unexpected value %v", a2.Val)
}
}
func TestSqlStringArray_YAML_Null(t *testing.T) {
var a SqlStringArray
data, err := yaml.Marshal(a)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(data) != "null\n" {
t.Errorf("expected null, got %q", data)
}
var a2 SqlStringArray
if err := yaml.Unmarshal([]byte("null\n"), &a2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if a2.Valid {
t.Error("expected invalid")
}
}
func TestSqlInt32Array_YAML(t *testing.T) {
a := NewSqlInt32Array([]int32{1, 2, 3})
data, err := yaml.Marshal(a)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var a2 SqlInt32Array
if err := yaml.Unmarshal(data, &a2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
for i, v := range a.Val {
if a2.Val[i] != v {
t.Errorf("index %d: expected %d, got %d", i, v, a2.Val[i])
}
}
}
func TestSqlUUIDArray_YAML(t *testing.T) {
ids := []uuid.UUID{uuid.New(), uuid.New()}
a := NewSqlUUIDArray(ids)
data, err := yaml.Marshal(a)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var a2 SqlUUIDArray
if err := yaml.Unmarshal(data, &a2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
for i, v := range ids {
if a2.Val[i] != v {
t.Errorf("index %d: expected %v, got %v", i, v, a2.Val[i])
}
}
}
func TestSqlVector_YAML(t *testing.T) {
v := NewSqlVector([]float32{0.1, 0.2, 0.3})
data, err := yaml.Marshal(v)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var v2 SqlVector
if err := yaml.Unmarshal(data, &v2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
for i, f := range v.Val {
if v2.Val[i] != f {
t.Errorf("index %d: expected %v, got %v", i, f, v2.Val[i])
}
}
}
// ── Array types: XML ─────────────────────────────────────────────────────────
type xmlStringArrayWrapper struct {
XMLName xml.Name `xml:"root"`
Tags SqlStringArray `xml:"tags"`
}
func TestSqlStringArray_XML(t *testing.T) {
w := xmlStringArrayWrapper{Tags: NewSqlStringArray([]string{"x", "y", "z"})}
data, err := xml.Marshal(w)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var w2 xmlStringArrayWrapper
if err := xml.Unmarshal(data, &w2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
want := []string{"x", "y", "z"}
if len(w2.Tags.Val) != len(want) {
t.Fatalf("expected %v, got %v", want, w2.Tags.Val)
}
for i := range want {
if w2.Tags.Val[i] != want[i] {
t.Errorf("index %d: expected %q, got %q", i, want[i], w2.Tags.Val[i])
}
}
}
func TestSqlStringArray_XML_Empty(t *testing.T) {
w := xmlStringArrayWrapper{}
data, err := xml.Marshal(w)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var w2 xmlStringArrayWrapper
if err := xml.Unmarshal(data, &w2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if len(w2.Tags.Val) != 0 {
t.Errorf("expected empty slice, got %v", w2.Tags.Val)
}
}
type xmlInt64ArrayWrapper struct {
XMLName xml.Name `xml:"root"`
Scores SqlInt64Array `xml:"scores"`
}
func TestSqlInt64Array_XML(t *testing.T) {
w := xmlInt64ArrayWrapper{Scores: NewSqlInt64Array([]int64{10, 20, 30})}
data, err := xml.Marshal(w)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var w2 xmlInt64ArrayWrapper
if err := xml.Unmarshal(data, &w2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
want := []int64{10, 20, 30}
for i := range want {
if w2.Scores.Val[i] != want[i] {
t.Errorf("index %d: expected %d, got %d", i, want[i], w2.Scores.Val[i])
}
}
}
type xmlUUIDArrayWrapper struct {
XMLName xml.Name `xml:"root"`
IDs SqlUUIDArray `xml:"ids"`
}
func TestSqlUUIDArray_XML(t *testing.T) {
ids := []uuid.UUID{uuid.New(), uuid.New()}
w := xmlUUIDArrayWrapper{IDs: NewSqlUUIDArray(ids)}
data, err := xml.Marshal(w)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var w2 xmlUUIDArrayWrapper
if err := xml.Unmarshal(data, &w2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
for i, id := range ids {
if w2.IDs.Val[i] != id {
t.Errorf("index %d: expected %v, got %v", i, id, w2.IDs.Val[i])
}
}
}
type xmlVectorWrapper struct {
XMLName xml.Name `xml:"root"`
Embedding SqlVector `xml:"embedding"`
}
func TestSqlVector_XML(t *testing.T) {
w := xmlVectorWrapper{Embedding: NewSqlVector([]float32{1.5, -2.5})}
data, err := xml.Marshal(w)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var w2 xmlVectorWrapper
if err := xml.Unmarshal(data, &w2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
want := []float32{1.5, -2.5}
for i := range want {
if w2.Embedding.Val[i] != want[i] {
t.Errorf("index %d: expected %v, got %v", i, want[i], w2.Embedding.Val[i])
}
}
}
// ── Combined struct round-trip ───────────────────────────────────────────────
type yamlXMLRecord struct {
XMLName xml.Name `xml:"record" yaml:"-" json:"-"`
ID SqlUUID `xml:"id" yaml:"id"`
Name SqlString `xml:"name" yaml:"name"`
Age SqlInt32 `xml:"age" yaml:"age"`
Active SqlBool `xml:"active" yaml:"active"`
Created SqlTimeStamp `xml:"created" yaml:"created"`
Tags SqlStringArray `xml:"tags" yaml:"tags"`
}
func TestCombinedRecord_YAML_RoundTrip(t *testing.T) {
id := uuid.New()
original := yamlXMLRecord{
ID: NewSqlUUID(id),
Name: NewSqlString("Ada"),
Age: NewSqlInt32(36),
Active: NewSqlBool(true),
Created: NewSqlTimeStamp(time.Date(2024, 3, 10, 12, 30, 0, 0, time.UTC)),
Tags: NewSqlStringArray([]string{"engineer", "mathematician"}),
}
data, err := yaml.Marshal(original)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var decoded yamlXMLRecord
if err := yaml.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if decoded.ID.UUID() != id {
t.Errorf("ID: expected %v, got %v", id, decoded.ID.UUID())
}
if decoded.Name.String() != "Ada" {
t.Errorf("Name: expected Ada, got %q", decoded.Name.String())
}
if decoded.Age.Int64() != 36 {
t.Errorf("Age: expected 36, got %d", decoded.Age.Int64())
}
if !decoded.Active.Bool() {
t.Error("Active: expected true")
}
if decoded.Created.Time().Format("2006-01-02T15:04:05") != "2024-03-10T12:30:00" {
t.Errorf("Created: expected 2024-03-10T12:30:00, got %v", decoded.Created.Time())
}
if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[0] != "engineer" {
t.Errorf("Tags: unexpected value %v", decoded.Tags.Val)
}
}
func TestCombinedRecord_XML_RoundTrip(t *testing.T) {
id := uuid.New()
original := yamlXMLRecord{
ID: NewSqlUUID(id),
Name: NewSqlString("Ada"),
Age: NewSqlInt32(36),
Active: NewSqlBool(true),
Created: NewSqlTimeStamp(time.Date(2024, 3, 10, 12, 30, 0, 0, time.UTC)),
Tags: NewSqlStringArray([]string{"engineer", "mathematician"}),
}
data, err := xml.Marshal(original)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var decoded yamlXMLRecord
if err := xml.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if decoded.ID.UUID() != id {
t.Errorf("ID: expected %v, got %v", id, decoded.ID.UUID())
}
if decoded.Name.String() != "Ada" {
t.Errorf("Name: expected Ada, got %q", decoded.Name.String())
}
if decoded.Age.Int64() != 36 {
t.Errorf("Age: expected 36, got %d", decoded.Age.Int64())
}
if !decoded.Active.Bool() {
t.Error("Active: expected true")
}
if decoded.Created.Time().Format("2006-01-02T15:04:05") != "2024-03-10T12:30:00" {
t.Errorf("Created: expected 2024-03-10T12:30:00, got %v", decoded.Created.Time())
}
if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[0] != "engineer" {
t.Errorf("Tags: unexpected value %v", decoded.Tags.Val)
}
}
+164
View File
@@ -0,0 +1,164 @@
package sqltypes
import (
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
)
// record mimics a typical DB model composed of sqltypes fields, exercising
// marshalling/unmarshalling of the whole set together as encoding/json would
// when used on a real struct (not just the individual types in isolation).
type record struct {
ID SqlUUID `json:"id"`
Name SqlString `json:"name"`
Bio SqlString `json:"bio"`
Age SqlInt32 `json:"age"`
Score SqlFloat64 `json:"score"`
Active SqlBool `json:"active"`
CreatedAt SqlTimeStamp `json:"created_at"`
BirthDate SqlDate `json:"birth_date"`
Avatar SqlByteArray `json:"avatar"`
Tags SqlStringArray `json:"tags"`
Scores SqlInt32Array `json:"scores"`
Metadata SqlJSONB `json:"metadata"`
Embedding SqlVector `json:"embedding"`
Extras []uuid.UUID `json:"-"`
}
func TestStruct_JSON_RoundTrip_AllFieldsPresent(t *testing.T) {
id := uuid.New()
createdAt := time.Date(2024, 3, 10, 12, 30, 0, 0, time.UTC)
birthDate := time.Date(1990, 5, 20, 0, 0, 0, 0, time.UTC)
original := record{
ID: NewSqlUUID(id),
Name: NewSqlString("Ada Lovelace"),
Bio: SqlString{}, // intentionally null
Age: NewSqlInt32(36),
Score: NewSqlFloat64(98.6),
Active: NewSqlBool(true),
CreatedAt: NewSqlTimeStamp(createdAt),
BirthDate: NewSqlDate(birthDate),
Avatar: NewSqlByteArray([]byte{0xDE, 0xAD, 0xBE, 0xEF}),
Tags: NewSqlStringArray([]string{"engineer", "mathematician"}),
Scores: NewSqlInt32Array([]int32{10, 20, 30}),
Metadata: SqlJSONB(`{"role":"admin"}`),
Embedding: NewSqlVector([]float32{0.1, 0.2, 0.3}),
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var decoded record
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if decoded.ID.UUID() != id {
t.Errorf("ID: expected %v, got %v", id, decoded.ID.UUID())
}
if decoded.Name.String() != "Ada Lovelace" {
t.Errorf("Name: expected Ada Lovelace, got %q", decoded.Name.String())
}
if decoded.Bio.Valid {
t.Errorf("Bio: expected invalid/null, got %v", decoded.Bio)
}
if decoded.Age.Int64() != 36 {
t.Errorf("Age: expected 36, got %d", decoded.Age.Int64())
}
if decoded.Score.Float64() != 98.6 {
t.Errorf("Score: expected 98.6, got %v", decoded.Score.Float64())
}
if !decoded.Active.Bool() {
t.Errorf("Active: expected true")
}
if decoded.CreatedAt.Time().Format("2006-01-02T15:04:05") != createdAt.Format("2006-01-02T15:04:05") {
t.Errorf("CreatedAt: expected %v, got %v", createdAt, decoded.CreatedAt.Time())
}
if decoded.BirthDate.String() != "1990-05-20" {
t.Errorf("BirthDate: expected 1990-05-20, got %q", decoded.BirthDate.String())
}
if string(decoded.Avatar.Val) != string(original.Avatar.Val) {
t.Errorf("Avatar: expected %v, got %v", original.Avatar.Val, decoded.Avatar.Val)
}
if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[0] != "engineer" {
t.Errorf("Tags: unexpected value %v", decoded.Tags.Val)
}
if len(decoded.Scores.Val) != 3 || decoded.Scores.Val[2] != 30 {
t.Errorf("Scores: unexpected value %v", decoded.Scores.Val)
}
m, err := decoded.Metadata.AsMap()
if err != nil || m["role"] != "admin" {
t.Errorf("Metadata: expected role=admin, got %v (err=%v)", m, err)
}
if len(decoded.Embedding.Val) != 3 || decoded.Embedding.Val[1] != 0.2 {
t.Errorf("Embedding: unexpected value %v", decoded.Embedding.Val)
}
}
func TestStruct_JSON_RoundTrip_AllNull(t *testing.T) {
var original record
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var decoded record
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if decoded.ID.Valid || decoded.Name.Valid || decoded.Age.Valid || decoded.Score.Valid ||
decoded.Active.Valid || decoded.CreatedAt.Valid || decoded.BirthDate.Valid ||
decoded.Avatar.Valid || decoded.Tags.Valid || decoded.Scores.Valid || decoded.Embedding.Valid {
t.Errorf("expected all fields invalid/null after round-trip, got %+v", decoded)
}
if decoded.Metadata != nil {
t.Errorf("expected nil Metadata, got %v", decoded.Metadata)
}
}
func TestStruct_JSON_UnmarshalFromRawJSON(t *testing.T) {
raw := `{
"id": "3e843d4e-6b3c-4f2e-9a1e-6f0b2f3c9d10",
"name": "Grace Hopper",
"bio": null,
"age": 85,
"score": 100,
"active": false,
"created_at": "2023-12-01T08:00:00",
"birth_date": "1906-12-09",
"avatar": "AQIDBA==",
"tags": ["navy", "compiler"],
"scores": [1, 2, 3],
"metadata": {"key": "value"},
"embedding": [1.0, 2.0]
}`
var decoded record
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if decoded.Name.String() != "Grace Hopper" {
t.Errorf("expected Grace Hopper, got %q", decoded.Name.String())
}
if decoded.Age.Int64() != 85 {
t.Errorf("expected age 85, got %d", decoded.Age.Int64())
}
if decoded.Active.Valid && decoded.Active.Bool() {
t.Errorf("expected active=false")
}
if string(decoded.Avatar.Val) != string([]byte{1, 2, 3, 4}) {
t.Errorf("expected avatar bytes [1 2 3 4], got %v", decoded.Avatar.Val)
}
if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[1] != "compiler" {
t.Errorf("unexpected tags %v", decoded.Tags.Val)
}
}
+180
View File
@@ -0,0 +1,180 @@
package sqltypes
import (
"database/sql"
"testing"
"github.com/google/uuid"
_ "modernc.org/sqlite"
)
// TestUUIDWithRealDatabase tests that SqlUUID works with actual database operations
func TestUUIDWithRealDatabase(t *testing.T) {
// Open an in-memory SQLite database
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Create a test table with UUID column
_, err = db.Exec(`
CREATE TABLE test_users (
id INTEGER PRIMARY KEY,
user_id TEXT,
name TEXT
)
`)
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
// Test 1: Insert with UUID
testUUID1 := uuid.New()
sqlUUID1 := NewSqlUUID(testUUID1)
_, err = db.Exec("INSERT INTO test_users (id, user_id, name) VALUES (?, ?, ?)",
1, sqlUUID1, "Alice")
if err != nil {
t.Fatalf("Failed to insert record: %v", err)
}
// Test 2: Update with UUID
testUUID2 := uuid.New()
sqlUUID2 := NewSqlUUID(testUUID2)
_, err = db.Exec("UPDATE test_users SET user_id = ? WHERE id = ?",
sqlUUID2, 1)
if err != nil {
t.Fatalf("Failed to update record: %v", err)
}
// Test 3: Read back and verify
var retrievedID string
var name string
err = db.QueryRow("SELECT user_id, name FROM test_users WHERE id = ?", 1).Scan(&retrievedID, &name)
if err != nil {
t.Fatalf("Failed to query record: %v", err)
}
if retrievedID != testUUID2.String() {
t.Errorf("Expected UUID %s, got %s", testUUID2.String(), retrievedID)
}
if name != "Alice" {
t.Errorf("Expected name 'Alice', got '%s'", name)
}
// Test 4: Insert with NULL UUID
nullUUID := SqlUUID{Valid: false}
_, err = db.Exec("INSERT INTO test_users (id, user_id, name) VALUES (?, ?, ?)",
2, nullUUID, "Bob")
if err != nil {
t.Fatalf("Failed to insert record with NULL UUID: %v", err)
}
// Test 5: Read NULL UUID back
var retrievedNullID sql.NullString
err = db.QueryRow("SELECT user_id FROM test_users WHERE id = ?", 2).Scan(&retrievedNullID)
if err != nil {
t.Fatalf("Failed to query NULL UUID record: %v", err)
}
if retrievedNullID.Valid {
t.Errorf("Expected NULL UUID, got %s", retrievedNullID.String)
}
t.Logf("All database operations with UUID succeeded!")
}
// TestUUIDValueReturnsString verifies that Value() returns string, not uuid.UUID
func TestUUIDValueReturnsString(t *testing.T) {
testUUID := uuid.New()
sqlUUID := NewSqlUUID(testUUID)
val, err := sqlUUID.Value()
if err != nil {
t.Fatalf("Value() failed: %v", err)
}
// The value should be a string, not a uuid.UUID
strVal, ok := val.(string)
if !ok {
t.Fatalf("Expected Value() to return string, got %T", val)
}
if strVal != testUUID.String() {
t.Errorf("Expected %s, got %s", testUUID.String(), strVal)
}
t.Logf("✓ Value() correctly returns string: %s", strVal)
}
// CustomStringableType is a custom type that implements fmt.Stringer
type CustomStringableType string
func (c CustomStringableType) String() string {
return "custom:" + string(c)
}
// TestCustomStringableType verifies that any type implementing fmt.Stringer works
func TestCustomStringableType(t *testing.T) {
customVal := CustomStringableType("test-value")
sqlCustom := SqlNull[CustomStringableType]{
Val: customVal,
Valid: true,
}
val, err := sqlCustom.Value()
if err != nil {
t.Fatalf("Value() failed: %v", err)
}
// Should return the result of String() method
strVal, ok := val.(string)
if !ok {
t.Fatalf("Expected Value() to return string, got %T", val)
}
expected := "custom:test-value"
if strVal != expected {
t.Errorf("Expected %s, got %s", expected, strVal)
}
t.Logf("✓ Custom Stringer type correctly converted to string: %s", strVal)
}
// TestStringMethodUsesStringer verifies that String() method also uses fmt.Stringer
func TestStringMethodUsesStringer(t *testing.T) {
// Test with UUID
testUUID := uuid.New()
sqlUUID := NewSqlUUID(testUUID)
strResult := sqlUUID.String()
if strResult != testUUID.String() {
t.Errorf("Expected UUID String() to return %s, got %s", testUUID.String(), strResult)
}
t.Logf("✓ UUID String() method: %s", strResult)
// Test with custom Stringer type
customVal := CustomStringableType("test-value")
sqlCustom := SqlNull[CustomStringableType]{
Val: customVal,
Valid: true,
}
customStr := sqlCustom.String()
expected := "custom:test-value"
if customStr != expected {
t.Errorf("Expected custom String() to return %s, got %s", expected, customStr)
}
t.Logf("✓ Custom Stringer String() method: %s", customStr)
// Test with regular type (should use fmt.Sprintf)
sqlInt := NewSqlInt64(42)
intStr := sqlInt.String()
if intStr != "42" {
t.Errorf("Expected int String() to return '42', got '%s'", intStr)
}
t.Logf("✓ Regular type String() method: %s", intStr)
}
+80 -17
View File
@@ -6,6 +6,8 @@ Generates Go source files with Bun model definitions from database schema inform
The Bun Writer converts RelSpec's internal database model representation into Go source code with Bun struct definitions, complete with proper tags, relationships, and table configuration.
With `--types sqltypes`, nullable fields use the [`pkg/sqltypes`](../../sqltypes/README.md) package.
## Features
- Generates Bun-compatible Go structs
@@ -46,45 +48,51 @@ 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 \
--to bun --to-path models/ --package models
# Inject computed/scan-only fields that are not present in the database schema
# (extra-fields.json contains the JSON array shown below)
relspec convert --from dbml --from-path schema.dbml \
--to bun --to-path models.go --package models \
--extra-fields extra-fields.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 +134,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 +150,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 +159,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
@@ -180,12 +188,67 @@ options := &writers.WriterOptions{
}
```
#### Extra fields
Use `Metadata["extra_fields"]` (or the CLI `--extra-fields <path>` flag) to inject
custom fields into generated Bun models without editing generated files. This is
intended for computed columns and scan-only query fields that Bun must scan into
but that are not real database table columns.
The CLI flag expects a path to a JSON file, not inline JSON. This avoids shell
quoting problems with struct tags and complex type names.
Each entry supports:
| Field | Required | Description |
|---|---:|---|
| `target_table` | No | Optional model scope. Accepts table name (`projects`), qualified table (`public.projects`), or model name (`ModelPublicProjects`). If omitted, the field is added to every generated model. |
| `name` | Yes | Go struct field name. |
| `type` | Yes | Go type to emit. |
| `bun_tag` | No | Raw Bun tag contents, e.g. `thought_count,scanonly`. |
| `json_tag` | No | Raw JSON tag contents. |
| `comment` | No | Optional line comment. |
```go
options := &writers.WriterOptions{
OutputPath: "models.go",
PackageName: "models",
Metadata: map[string]any{
"extra_fields": `[
{
"target_table": "projects",
"name": "ThoughtCount",
"type": "sql_types.SqlInt64",
"bun_tag": "thought_count,scanonly",
"json_tag": "thought_count",
"comment": "Computed by ResolveSpec queries"
}
]`,
},
}
```
Example `extra-fields.json`:
```json
[
{
"target_table": "projects",
"name": "ThoughtCount",
"type": "sql_types.SqlInt64",
"bun_tag": "thought_count,scanonly",
"json_tag": "thought_count",
"comment": "Computed by ResolveSpec queries"
}
]
```
## Notes
- 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
+89 -3
View File
@@ -1,6 +1,8 @@
package bun
import (
"encoding/json"
"fmt"
"sort"
"strings"
@@ -32,6 +34,90 @@ type ModelData struct {
PrimaryKeyIDType string // Helper method GetID/SetID/UpdateID type
IDColumnName string // Name of the ID column in database
Prefix string // 3-letter prefix
// ExtraFields are user-defined fields added via generator config (issue #4)
ExtraFields []*FieldData `json:"extra_fields,omitempty"`
}
// ExtraFieldConfig represents a custom field to be added to generated models
type ExtraFieldConfig struct {
Name string `json:"name"`
Type string `json:"type"`
BunTag string `json:"bun_tag,omitempty"`
JSONTag string `json:"json_tag,omitempty"`
Comment string `json:"comment,omitempty"`
TargetTable string `json:"target_table,omitempty"` // optional: if set, field only applies to this table
}
// LoadExtraFieldsFromMetadata loads custom field configurations from metadata map.
// Accepts either a JSON-encoded array of fields (for CLI use) or a structured list
// (for programmatic/sidecar file use). Each entry must have "name" and "type";
// an optional "target_table" scopes the field to one table only.
func LoadExtraFieldsFromMetadata(metadata map[string]interface{}) []ExtraFieldConfig {
if metadata == nil {
return nil
}
extraFieldsRaw, ok := metadata["extra_fields"]
if !ok || extraFieldsRaw == nil {
return nil
}
// Try to parse as JSON string first (from CLI flag)
if strVal, ok := extraFieldsRaw.(string); ok {
var fields []ExtraFieldConfig
if err := json.Unmarshal([]byte(strVal), &fields); err == nil && len(fields) > 0 {
return filterValidFields(fields)
}
}
// Try to parse as structured data (from sidecar file or programmatic API)
extraFieldsList, ok := extraFieldsRaw.([]interface{})
if !ok || len(extraFieldsList) == 0 {
return nil
}
fields := make([]ExtraFieldConfig, 0, len(extraFieldsList))
for _, item := range extraFieldsList {
if fieldMap, ok := item.(map[string]interface{}); ok {
field := ExtraFieldConfig{
Name: toString(fieldMap["name"]),
Type: toString(fieldMap["type"]),
BunTag: toString(fieldMap["bun_tag"]),
JSONTag: toString(fieldMap["json_tag"]),
Comment: toString(fieldMap["comment"]),
TargetTable: toString(fieldMap["target_table"]),
}
if field.Name != "" && field.Type != "" {
fields = append(fields, field)
}
}
}
return filterValidFields(fields)
}
// filterValidFields removes entries that are missing required fields.
func filterValidFields(fields []ExtraFieldConfig) []ExtraFieldConfig {
var valid []ExtraFieldConfig
for _, f := range fields {
if f.Name != "" && f.Type != "" {
valid = append(valid, f)
}
}
return valid
}
func toString(v interface{}) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
default:
return fmt.Sprintf("%v", val)
}
}
// FieldData represents a single field in a struct
@@ -141,10 +227,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 +289,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
+7
View File
@@ -23,6 +23,13 @@ type {{.Name}} struct {
{{- range .Fields}}
{{.Name}} {{.Type}} ` + "`bun:\"{{.BunTag}}\" json:\"{{.JSONTag}}\"`" + `{{if .Comment}} // {{.Comment}}{{end}}
{{- end}}
{{- if .ExtraFields}}
// --- Custom fields (managed by relspecgo generator config, issue #4) ---
{{- range .ExtraFields}}
{{.Name}} {{.Type}} ` + "`bun:\"{{.BunTag}}\" json:\"{{.JSONTag}}\"`" + `{{if .Comment}} // {{.Comment}}{{end}}
{{- end}}
{{- end}}
}
{{if .Config.GenerateTableName}}
// TableName returns the table name for {{.Name}}
+9 -9
View File
@@ -12,18 +12,18 @@ import (
// TypeMapper handles type conversions between SQL and Go types for Bun
type TypeMapper struct {
sqlTypesAlias string
typeStyle string // writers.NullableTypeResolveSpec | writers.NullableTypeStdlib
typeStyle string // writers.NullableTypeSqlTypes | writers.NullableTypeStdlib | writers.NullableTypeBaselib
}
// NewTypeMapper creates a new TypeMapper.
// typeStyle should be writers.NullableTypeResolveSpec or writers.NullableTypeStdlib;
// an empty string defaults to resolvespec.
// typeStyle should be writers.NullableTypeSqlTypes, writers.NullableTypeStdlib, or
// writers.NullableTypeBaselib; an empty string defaults to baselib.
func NewTypeMapper(typeStyle string) *TypeMapper {
if typeStyle == "" {
typeStyle = writers.NullableTypeBaselib
}
return &TypeMapper{
sqlTypesAlias: "resolvespec_common",
sqlTypesAlias: "sql_types",
typeStyle: typeStyle,
}
}
@@ -50,7 +50,7 @@ func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string {
return tm.baselibNullableGoType(baseType)
}
// resolvespec (default): use base Go types only for simple NOT NULL fields.
// sqltypes: use base Go types only for simple NOT NULL fields.
if notNull && tm.isSimpleType(baseType) {
return tm.baseGoType(baseType)
}
@@ -106,11 +106,11 @@ func (tm *TypeMapper) baseGoType(sqlType string) string {
return goType
}
// Default to resolvespec type
// Default to sqltypes type
return tm.bunGoType(sqlType)
}
// bunGoType returns the Bun/ResolveSpec common type
// bunGoType returns the Bun/sqltypes common type
func (tm *TypeMapper) bunGoType(sqlType string) string {
typeMap := map[string]string{
// Integer types
@@ -461,9 +461,9 @@ func (tm *TypeMapper) NeedsFmtImport(generateGetIDStr bool) bool {
return generateGetIDStr
}
// GetSQLTypesImport returns the import path for the ResolveSpec spectypes package.
// GetSQLTypesImport returns the import path for the sqltypes package.
func (tm *TypeMapper) GetSQLTypesImport() string {
return "github.com/bitechdev/ResolveSpec/pkg/spectypes"
return "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
}
// GetNullableTypeImportLine returns the full Go import line for the nullable type
+45 -2
View File
@@ -51,6 +51,43 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
return w.writeSingleFile(db)
}
// addExtraFields appends user-defined extra fields to matching models.
// Fields may be scoped by target_table (table name, schema.table name, or model name).
// Unscoped fields apply to every generated model.
func (w *Writer) addExtraFields(templateData *TemplateData) {
extraFields := LoadExtraFieldsFromMetadata(w.options.Metadata)
if len(extraFields) == 0 {
return
}
for _, model := range templateData.Models {
for _, ef := range extraFields {
if !extraFieldMatchesModel(ef, model) {
continue
}
fieldData := &FieldData{
Name: resolveFieldNameCollision(ef.Name),
Type: ef.Type,
BunTag: ef.BunTag,
JSONTag: ef.JSONTag,
Comment: ef.Comment,
}
model.ExtraFields = append(model.ExtraFields, fieldData)
}
}
}
func extraFieldMatchesModel(field ExtraFieldConfig, model *ModelData) bool {
if field.TargetTable == "" {
return true
}
return field.TargetTable == model.TableNameOnly ||
field.TargetTable == model.TableName ||
field.TargetTable == model.Name
}
// WriteSchema writes a schema as Bun models
func (w *Writer) WriteSchema(schema *models.Schema) error {
// Create a temporary database with just this schema
@@ -80,7 +117,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
@@ -107,6 +144,9 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
templateData.AddImport("\"fmt\"")
}
// Apply extra fields from generator config (issue #4)
w.addExtraFields(templateData)
// Finalize imports
templateData.FinalizeImports()
@@ -177,7 +217,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
@@ -200,6 +240,9 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
templateData.AddImport("\"fmt\"")
}
// Apply extra fields from generator config (issue #4)
w.addExtraFields(templateData)
// Finalize imports
templateData.FinalizeImports()
+163 -3
View File
@@ -550,7 +550,7 @@ func TestWriter_FieldNameCollision(t *testing.T) {
}
// Verify NO field named just "TableName" (without underscore)
if strings.Contains(generated, "TableName resolvespec_common") || strings.Contains(generated, "TableName string") {
if strings.Contains(generated, "TableName sql_types") || strings.Contains(generated, "TableName string") {
t.Errorf("Field 'TableName' without underscore should not exist (would conflict with method)\nGenerated:\n%s", generated)
}
}
@@ -827,9 +827,9 @@ func TestTypeMapper_BuildBunTag(t *testing.T) {
t.Errorf("BuildBunTag() = %q, missing %q", result, part)
}
}
// resolvespec mode must NOT add "array" — SqlXxxArray uses sql.Scanner
// sqltypes mode must NOT add "array" — SqlXxxArray uses sql.Scanner
if strings.Contains(result, ",array,") || strings.HasSuffix(result, ",array,") {
t.Errorf("BuildBunTag() = %q, must not contain 'array' in resolvespec mode", result)
t.Errorf("BuildBunTag() = %q, must not contain 'array' in sqltypes mode", result)
}
})
}
@@ -855,6 +855,166 @@ func TestTypeMapper_BuildBunTag_StdlibArrayHasArrayTag(t *testing.T) {
}
}
func TestExtraFields_LoadFromMetadata(t *testing.T) {
tests := []struct {
name string
metadata map[string]interface{}
expected int
}{
{
name: "nil metadata",
metadata: nil,
expected: 0,
},
{
name: "no extra_fields key",
metadata: map[string]interface{}{
"generate_table_name": true,
},
expected: 0,
},
{
name: "empty array",
metadata: map[string]interface{}{
"extra_fields": []interface{}{},
},
expected: 0,
},
{
name: "JSON string format",
metadata: map[string]interface{}{
"extra_fields": `[{"name":"ThoughtCount","type":"int64","bun_tag":"thought_count,scanonly","json_tag":"thought_count"}]`,
},
expected: 1,
},
{
name: "structured format",
metadata: map[string]interface{}{
"extra_fields": []interface{}{
map[string]interface{}{
"name": "ThoughtCount",
"type": "int64",
"bun_tag": "thought_count,scanonly",
"json_tag": "thought_count",
},
},
},
expected: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fields := LoadExtraFieldsFromMetadata(tt.metadata)
if len(fields) != tt.expected {
t.Errorf("expected %d fields, got %d", tt.expected, len(fields))
}
})
}
}
func TestExtraFields_InSingleFile(t *testing.T) {
table := models.InitTable("projects", "public")
table.Columns["id"] = &models.Column{
Name: "id",
Type: "bigint",
NotNull: true,
IsPrimaryKey: true,
}
table.Columns["name"] = &models.Column{
Name: "name",
Type: "varchar",
Length: 255,
Sequence: 1,
}
opts := &writers.WriterOptions{
PackageName: "models",
OutputPath: t.TempDir() + "/test.go",
Metadata: map[string]interface{}{
"extra_fields": `[{"name":"ThoughtCount","type":"int64","bun_tag":"thought_count,scanonly","json_tag":"thought_count"}]`,
},
}
writer := NewWriter(opts)
err := writer.WriteTable(table)
if err != nil {
t.Fatalf("WriteTable failed: %v", err)
}
content, _ := os.ReadFile(opts.OutputPath)
generated := string(content)
// Verify extra field is present with separator comment
expectations := []string{
"// --- Custom fields (managed by relspecgo generator config, issue #4) ---",
"ThoughtCount int64 `bun:\"thought_count,scanonly\" json:\"thought_count\"`",
}
for _, expected := range expectations {
if !strings.Contains(generated, expected) {
t.Errorf("Generated code missing: %q\nGenerated:\n%s", expected, generated)
}
}
}
func TestExtraFields_InMultiFile(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
users := models.InitTable("users", "public")
users.Columns["id"] = &models.Column{
Name: "id",
Type: "bigint",
NotNull: true,
IsPrimaryKey: true,
}
schema.Tables = append(schema.Tables, users)
posts := models.InitTable("posts", "public")
posts.Columns["id"] = &models.Column{
Name: "id",
Type: "bigint",
NotNull: true,
IsPrimaryKey: true,
}
schema.Tables = append(schema.Tables, posts)
db.Schemas = append(db.Schemas, schema)
tmpDir := t.TempDir()
opts := &writers.WriterOptions{
PackageName: "models",
OutputPath: tmpDir,
Metadata: map[string]interface{}{
"multi_file": true,
"extra_fields": `[{"target_table":"users","name":"PostCount","type":"int64","bun_tag":"post_count,scanonly","json_tag":"post_count"}]`,
},
}
writer := NewWriter(opts)
err := writer.WriteDatabase(db)
if err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
// Check users file has the extra field (matched by table name "users")
usersContent, _ := os.ReadFile(tmpDir + "/sql_public_users.go")
usersStr := string(usersContent)
if !strings.Contains(usersStr, "PostCount int64 `bun:\"post_count,scanonly\"") {
t.Errorf("Extra field not found in users file:\n%s", usersStr)
}
// Posts file should NOT have the extra field (name mismatch)
postsContent, _ := os.ReadFile(tmpDir + "/sql_public_posts.go")
postsStr := string(postsContent)
if strings.Contains(postsStr, "PostCount") {
t.Errorf("Extra field should not be in posts file:\n%s", postsStr)
}
}
func TestTypeMapper_BuildBunTag_PreservesExplicitTypeModifiers(t *testing.T) {
mapper := NewTypeMapper("")
+11 -9
View File
@@ -6,6 +6,8 @@ Generates Go source files with GORM model definitions from database schema infor
The GORM Writer converts RelSpec's internal database model representation into Go source code with GORM struct definitions, complete with proper tags, relationships, and methods.
With `--types sqltypes`, nullable fields use the [`pkg/sqltypes`](../../sqltypes/README.md) package.
## Features
- Generates GORM-compatible Go structs
@@ -48,19 +50,19 @@ func main() {
### CLI Examples
```bash
# Generate GORM models from a DBML schema (default: resolvespec types)
# Generate GORM models from a DBML schema (default: baselib pointer types)
relspec convert --from dbml --from-path schema.dbml \
--to gorm --to-path models.go --package models
# Use standard library database/sql nullable types instead of resolvespec
# Use standard library database/sql nullable types instead
relspec convert --from dbml --from-path schema.dbml \
--to gorm --to-path models.go --package models \
--types stdlib
# Explicitly select resolvespec types (same as omitting --types)
# Select sqltypes package types (git.warky.dev/wdevs/relspecgo/pkg/sqltypes)
relspec convert --from pgsql --from-conn "postgres://localhost/mydb" \
--to gorm --to-path models.go --package models \
--types resolvespec
--types sqltypes
# Multi-file output (one file per table)
relspec convert --from json --from-path schema.json \
@@ -89,13 +91,13 @@ Files are named: `sql_{schema}_{table}.go`
## Generated Code Examples
### Default — resolvespec types (`--types resolvespec`)
### sqltypes package types (`--types sqltypes`)
```go
package models
import (
sql_types "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
)
type ModelUser struct {
@@ -141,11 +143,11 @@ func (ModelUser) TableName() string {
Controls which Go package is used for nullable column types. Set via the `--types` CLI flag or `WriterOptions.NullableTypes`:
```go
// Use resolvespec types (default — omit NullableTypes or set to "resolvespec")
// Use sqltypes package types
options := &writers.WriterOptions{
OutputPath: "models.go",
PackageName: "models",
NullableTypes: writers.NullableTypeResolveSpec,
NullableTypes: writers.NullableTypeSqlTypes,
}
// Use standard library database/sql types
@@ -176,7 +178,7 @@ options := &writers.WriterOptions{
The nullable type package is selected with `--types` (or `WriterOptions.NullableTypes`).
| SQL Type | NOT NULL — both | Nullable — resolvespec | Nullable — stdlib |
| SQL Type | NOT NULL — both | Nullable — sqltypes | Nullable — stdlib |
|---|---|---|---|
| `bigint` | `int64` | `SqlInt64` | `sql.NullInt64` |
| `integer` | `int32` | `SqlInt32` | `sql.NullInt32` |
+6 -6
View File
@@ -12,12 +12,12 @@ import (
// TypeMapper handles type conversions between SQL and Go types
type TypeMapper struct {
sqlTypesAlias string
typeStyle string // writers.NullableTypeResolveSpec | writers.NullableTypeStdlib
typeStyle string // writers.NullableTypeSqlTypes | writers.NullableTypeStdlib | writers.NullableTypeBaselib
}
// NewTypeMapper creates a new TypeMapper.
// typeStyle should be writers.NullableTypeResolveSpec or writers.NullableTypeStdlib;
// an empty string defaults to resolvespec.
// typeStyle should be writers.NullableTypeSqlTypes, writers.NullableTypeStdlib, or
// writers.NullableTypeBaselib; an empty string defaults to baselib.
func NewTypeMapper(typeStyle string) *TypeMapper {
if typeStyle == "" {
typeStyle = writers.NullableTypeBaselib
@@ -50,7 +50,7 @@ func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string {
return tm.baselibNullableGoType(baseType)
}
// resolvespec (default)
// sqltypes
if notNull {
return tm.baseGoType(baseType)
}
@@ -505,9 +505,9 @@ func (tm *TypeMapper) NeedsFmtImport(generateGetIDStr bool) bool {
return generateGetIDStr
}
// GetSQLTypesImport returns the import path for the ResolveSpec spectypes package.
// GetSQLTypesImport returns the import path for the sqltypes package.
func (tm *TypeMapper) GetSQLTypesImport() string {
return "github.com/bitechdev/ResolveSpec/pkg/spectypes"
return "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
}
// GetNullableTypeImportLine returns the full Go import line for the nullable type
+2 -2
View File
@@ -77,7 +77,7 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
packageName := w.getPackageName()
templateData := NewTemplateData(packageName, w.config)
// Add nullable types import (resolvespec or stdlib depending on options)
// Add nullable types import (sqltypes or stdlib depending on options)
templateData.AddImport(w.typeMapper.GetNullableTypeImportLine())
// Collect all models
@@ -171,7 +171,7 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
// Create template data for this single table
templateData := NewTemplateData(packageName, w.config)
// Add nullable types import (resolvespec or stdlib depending on options)
// Add nullable types import (sqltypes or stdlib depending on options)
templateData.AddImport(w.typeMapper.GetNullableTypeImportLine())
// Create model data
+4 -4
View File
@@ -23,9 +23,9 @@ type Writer interface {
// NullableType constants control which Go package is used for nullable column types
// in code-generation writers (Bun, GORM).
const (
// NullableTypeResolveSpec uses github.com/bitechdev/ResolveSpec/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) — github.com/bitechdev/ResolveSpec/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.