Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7805d9b6f0 | |||
| 40a0e6a0aa | |||
| 99d63aa5f4 | |||
| ee94ddc133 | |||
| 651c7aa3f4 | |||
| 1cd9cd8803 | |||
| ab735d1f3a | |||
| e29c7e31c1 | |||
| 43f4680176 | |||
| d9f27c1775 | |||
| bb7ceb37fe | |||
| 6a759ef3d1 | |||
| cb735f0754 | |||
| 80fb49bc5e | |||
| 9190df81dd | |||
| 9235ef5e08 | |||
| b91d6b33b5 | |||
| 30ef1db010 | |||
| 2d97a47ee1 | |||
| 72200ea72e | |||
| 608893a3d6 | |||
| 53ff745d5d | |||
| 17bc8ed395 | |||
| a447b68b22 | |||
| 4303dcf59b | |||
| e828d48798 | |||
| 6e470a9239 | |||
| 096815fe49 | |||
| b8f60203cb | |||
| 15763f60cc | |||
| 6d2884f5cf | |||
| f192decff8 | |||
| 8b906cf4a3 | |||
| 0a3966e6fc | |||
| d30fc24f55 | |||
| 16a489d0b8 | |||
| 3524e86282 | |||
| 1e54fdcd7f | |||
| 3d9cc7ec58 |
@@ -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)
|
||||
|
||||
+30
-30
@@ -43,15 +43,17 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
convertSourceType string
|
||||
convertSourcePath string
|
||||
convertSourceConn string
|
||||
convertFromList []string
|
||||
convertTargetType string
|
||||
convertTargetPath string
|
||||
convertPackageName string
|
||||
convertSchemaFilter string
|
||||
convertFlattenSchema bool
|
||||
convertSourceType string
|
||||
convertSourcePath string
|
||||
convertSourceConn string
|
||||
convertFromList []string
|
||||
convertTargetType string
|
||||
convertTargetPath string
|
||||
convertPackageName string
|
||||
convertSchemaFilter string
|
||||
convertFlattenSchema bool
|
||||
convertNullableTypes string
|
||||
convertContinueOnError bool
|
||||
)
|
||||
|
||||
var convertCmd = &cobra.Command{
|
||||
@@ -175,6 +177,8 @@ 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 'sqltypes'")
|
||||
convertCmd.Flags().BoolVar(&convertContinueOnError, "continue-on-error", false, "Prepend \\set ON_ERROR_STOP off to generated SQL so psql continues past errors (pgsql output only)")
|
||||
|
||||
err := convertCmd.MarkFlagRequired("from")
|
||||
if err != nil {
|
||||
@@ -241,7 +245,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); err != nil {
|
||||
if err := writeDatabase(db, convertTargetType, convertTargetPath, convertPackageName, convertSchemaFilter, convertFlattenSchema, convertNullableTypes, convertContinueOnError); err != nil {
|
||||
return fmt.Errorf("failed to write target: %w", err)
|
||||
}
|
||||
|
||||
@@ -284,79 +288,79 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DBML format")
|
||||
}
|
||||
reader = dbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dbml.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DCTX format")
|
||||
}
|
||||
reader = dctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dctx.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DrawDB format")
|
||||
}
|
||||
reader = drawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drawdb.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for JSON format")
|
||||
}
|
||||
reader = json.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = json.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "yaml", "yml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for YAML format")
|
||||
}
|
||||
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = yaml.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "pgsql", "postgres", "postgresql":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("connection string is required for PostgreSQL format")
|
||||
}
|
||||
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
reader = pgsql.NewReader(newReaderOptions("", connString))
|
||||
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for GORM format")
|
||||
}
|
||||
reader = gorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = gorm.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Bun format")
|
||||
}
|
||||
reader = bun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = bun.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Drizzle format")
|
||||
}
|
||||
reader = drizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drizzle.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Prisma format")
|
||||
}
|
||||
reader = prisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = prisma.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for TypeORM format")
|
||||
}
|
||||
reader = typeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = typeorm.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "graphql", "gql":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for GraphQL format")
|
||||
}
|
||||
reader = graphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = graphql.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "mssql", "sqlserver", "mssql2016", "mssql2017", "mssql2019", "mssql2022":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("connection string is required for MSSQL format")
|
||||
}
|
||||
reader = mssql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
reader = mssql.NewReader(newReaderOptions("", connString))
|
||||
|
||||
case "sqlite", "sqlite3":
|
||||
// SQLite can use either file path or connection string
|
||||
@@ -367,7 +371,7 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("file path or connection string is required for SQLite format")
|
||||
}
|
||||
reader = sqlite.NewReader(&readers.ReaderOptions{FilePath: dbPath})
|
||||
reader = sqlite.NewReader(newReaderOptions(dbPath, ""))
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported source format: %s", dbType)
|
||||
@@ -381,14 +385,10 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool) error {
|
||||
func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool, nullableTypes string, continueOnError bool) error {
|
||||
var writer writers.Writer
|
||||
|
||||
writerOpts := &writers.WriterOptions{
|
||||
OutputPath: outputPath,
|
||||
PackageName: packageName,
|
||||
FlattenSchema: flattenSchema,
|
||||
}
|
||||
writerOpts := newWriterOptions(outputPath, packageName, flattenSchema, nullableTypes, continueOnError)
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml":
|
||||
|
||||
+26
-26
@@ -240,62 +240,62 @@ func readDatabaseForEdit(dbType, filePath, connString, label string) (*models.Da
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DBML format", label)
|
||||
}
|
||||
reader = dbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dbml.NewReader(newReaderOptions(filePath, ""))
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DCTX format", label)
|
||||
}
|
||||
reader = dctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dctx.NewReader(newReaderOptions(filePath, ""))
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DrawDB format", label)
|
||||
}
|
||||
reader = drawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drawdb.NewReader(newReaderOptions(filePath, ""))
|
||||
case "graphql":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for GraphQL format", label)
|
||||
}
|
||||
reader = graphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = graphql.NewReader(newReaderOptions(filePath, ""))
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for JSON format", label)
|
||||
}
|
||||
reader = json.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = json.NewReader(newReaderOptions(filePath, ""))
|
||||
case "yaml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for YAML format", label)
|
||||
}
|
||||
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = yaml.NewReader(newReaderOptions(filePath, ""))
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for GORM format", label)
|
||||
}
|
||||
reader = gorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = gorm.NewReader(newReaderOptions(filePath, ""))
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Bun format", label)
|
||||
}
|
||||
reader = bun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = bun.NewReader(newReaderOptions(filePath, ""))
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Drizzle format", label)
|
||||
}
|
||||
reader = drizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drizzle.NewReader(newReaderOptions(filePath, ""))
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Prisma format", label)
|
||||
}
|
||||
reader = prisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = prisma.NewReader(newReaderOptions(filePath, ""))
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for TypeORM format", label)
|
||||
}
|
||||
reader = typeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = typeorm.NewReader(newReaderOptions(filePath, ""))
|
||||
case "pgsql":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label)
|
||||
}
|
||||
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
reader = pgsql.NewReader(newReaderOptions("", connString))
|
||||
case "sqlite", "sqlite3":
|
||||
// SQLite can use either file path or connection string
|
||||
dbPath := filePath
|
||||
@@ -305,7 +305,7 @@ func readDatabaseForEdit(dbType, filePath, connString, label string) (*models.Da
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("%s: file path or connection string is required for SQLite format", label)
|
||||
}
|
||||
reader = sqlite.NewReader(&readers.ReaderOptions{FilePath: dbPath})
|
||||
reader = sqlite.NewReader(newReaderOptions(dbPath, ""))
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: unsupported format: %s", label, dbType)
|
||||
}
|
||||
@@ -323,31 +323,31 @@ func writeDatabaseForEdit(dbType, filePath, connString string, db *models.Databa
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml":
|
||||
writer = wdbml.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wdbml.NewWriter(newWriterOptions(filePath, "", false, "", false))
|
||||
case "dctx":
|
||||
writer = wdctx.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wdctx.NewWriter(newWriterOptions(filePath, "", false, "", false))
|
||||
case "drawdb":
|
||||
writer = wdrawdb.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wdrawdb.NewWriter(newWriterOptions(filePath, "", false, "", false))
|
||||
case "graphql":
|
||||
writer = wgraphql.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wgraphql.NewWriter(newWriterOptions(filePath, "", false, "", false))
|
||||
case "json":
|
||||
writer = wjson.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wjson.NewWriter(newWriterOptions(filePath, "", false, "", false))
|
||||
case "yaml":
|
||||
writer = wyaml.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wyaml.NewWriter(newWriterOptions(filePath, "", false, "", false))
|
||||
case "gorm":
|
||||
writer = wgorm.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wgorm.NewWriter(newWriterOptions(filePath, "", false, "", false))
|
||||
case "bun":
|
||||
writer = wbun.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wbun.NewWriter(newWriterOptions(filePath, "", false, "", false))
|
||||
case "drizzle":
|
||||
writer = wdrizzle.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wdrizzle.NewWriter(newWriterOptions(filePath, "", false, "", false))
|
||||
case "prisma":
|
||||
writer = wprisma.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wprisma.NewWriter(newWriterOptions(filePath, "", false, "", false))
|
||||
case "typeorm":
|
||||
writer = wtypeorm.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wtypeorm.NewWriter(newWriterOptions(filePath, "", false, "", false))
|
||||
case "sqlite", "sqlite3":
|
||||
writer = wsqlite.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wsqlite.NewWriter(newWriterOptions(filePath, "", false, "", false))
|
||||
case "pgsql":
|
||||
writer = wpgsql.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wpgsql.NewWriter(newWriterOptions(filePath, "", false, "", false))
|
||||
default:
|
||||
return fmt.Errorf("%s: unsupported format: %s", label, dbType)
|
||||
}
|
||||
|
||||
+13
-13
@@ -221,73 +221,73 @@ func readDatabaseForInspect(dbType, filePath, connString string) (*models.Databa
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DBML format")
|
||||
}
|
||||
reader = dbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dbml.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DCTX format")
|
||||
}
|
||||
reader = dctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dctx.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DrawDB format")
|
||||
}
|
||||
reader = drawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drawdb.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "graphql":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for GraphQL format")
|
||||
}
|
||||
reader = graphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = graphql.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for JSON format")
|
||||
}
|
||||
reader = json.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = json.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "yaml", "yml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for YAML format")
|
||||
}
|
||||
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = yaml.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for GORM format")
|
||||
}
|
||||
reader = gorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = gorm.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Bun format")
|
||||
}
|
||||
reader = bun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = bun.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Drizzle format")
|
||||
}
|
||||
reader = drizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drizzle.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Prisma format")
|
||||
}
|
||||
reader = prisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = prisma.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for TypeORM format")
|
||||
}
|
||||
reader = typeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = typeorm.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "pgsql", "postgres", "postgresql":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("connection string is required for PostgreSQL format")
|
||||
}
|
||||
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
reader = pgsql.NewReader(newReaderOptions("", connString))
|
||||
|
||||
case "sqlite", "sqlite3":
|
||||
// SQLite can use either file path or connection string
|
||||
@@ -298,7 +298,7 @@ func readDatabaseForInspect(dbType, filePath, connString string) (*models.Databa
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("file path or connection string is required for SQLite format")
|
||||
}
|
||||
reader = sqlite.NewReader(&readers.ReaderOptions{FilePath: dbPath})
|
||||
reader = sqlite.NewReader(newReaderOptions(dbPath, ""))
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", dbType)
|
||||
|
||||
+31
-26
@@ -258,6 +258,11 @@ func runMerge(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, " ✓ Merge complete\n\n")
|
||||
fmt.Fprintf(os.Stderr, "%s\n", merge.GetMergeSummary(result))
|
||||
|
||||
if strings.EqualFold(mergeOutputType, "pgsql") && len(result.TypeConflicts) > 0 {
|
||||
return fmt.Errorf("merge detected conflicting existing column types and cannot safely continue with pgsql output\n%s",
|
||||
merge.GetColumnTypeConflictSummary(result, 10))
|
||||
}
|
||||
|
||||
// Step 4: Write output
|
||||
fmt.Fprintf(os.Stderr, "\n[4/4] Writing output...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", mergeOutputType)
|
||||
@@ -284,62 +289,62 @@ func readDatabaseForMerge(dbType, filePath, connString, label string) (*models.D
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DBML format", label)
|
||||
}
|
||||
reader = dbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dbml.NewReader(newReaderOptions(filePath, ""))
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DCTX format", label)
|
||||
}
|
||||
reader = dctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dctx.NewReader(newReaderOptions(filePath, ""))
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DrawDB format", label)
|
||||
}
|
||||
reader = drawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drawdb.NewReader(newReaderOptions(filePath, ""))
|
||||
case "graphql":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for GraphQL format", label)
|
||||
}
|
||||
reader = graphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = graphql.NewReader(newReaderOptions(filePath, ""))
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for JSON format", label)
|
||||
}
|
||||
reader = json.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = json.NewReader(newReaderOptions(filePath, ""))
|
||||
case "yaml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for YAML format", label)
|
||||
}
|
||||
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = yaml.NewReader(newReaderOptions(filePath, ""))
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for GORM format", label)
|
||||
}
|
||||
reader = gorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = gorm.NewReader(newReaderOptions(filePath, ""))
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Bun format", label)
|
||||
}
|
||||
reader = bun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = bun.NewReader(newReaderOptions(filePath, ""))
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Drizzle format", label)
|
||||
}
|
||||
reader = drizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drizzle.NewReader(newReaderOptions(filePath, ""))
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Prisma format", label)
|
||||
}
|
||||
reader = prisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = prisma.NewReader(newReaderOptions(filePath, ""))
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for TypeORM format", label)
|
||||
}
|
||||
reader = typeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = typeorm.NewReader(newReaderOptions(filePath, ""))
|
||||
case "pgsql":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label)
|
||||
}
|
||||
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
reader = pgsql.NewReader(newReaderOptions("", connString))
|
||||
case "sqlite", "sqlite3":
|
||||
// SQLite can use either file path or connection string
|
||||
dbPath := filePath
|
||||
@@ -349,7 +354,7 @@ func readDatabaseForMerge(dbType, filePath, connString, label string) (*models.D
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("%s: file path or connection string is required for SQLite format", label)
|
||||
}
|
||||
reader = sqlite.NewReader(&readers.ReaderOptions{FilePath: dbPath})
|
||||
reader = sqlite.NewReader(newReaderOptions(dbPath, ""))
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: unsupported format '%s'", label, dbType)
|
||||
}
|
||||
@@ -370,61 +375,61 @@ func writeDatabaseForMerge(dbType, filePath, connString string, db *models.Datab
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for DBML format", label)
|
||||
}
|
||||
writer = wdbml.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wdbml.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for DCTX format", label)
|
||||
}
|
||||
writer = wdctx.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wdctx.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for DrawDB format", label)
|
||||
}
|
||||
writer = wdrawdb.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wdrawdb.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
|
||||
case "graphql":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for GraphQL format", label)
|
||||
}
|
||||
writer = wgraphql.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wgraphql.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for JSON format", label)
|
||||
}
|
||||
writer = wjson.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wjson.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
|
||||
case "yaml":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for YAML format", label)
|
||||
}
|
||||
writer = wyaml.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wyaml.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for GORM format", label)
|
||||
}
|
||||
writer = wgorm.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wgorm.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for Bun format", label)
|
||||
}
|
||||
writer = wbun.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wbun.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for Drizzle format", label)
|
||||
}
|
||||
writer = wdrizzle.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wdrizzle.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for Prisma format", label)
|
||||
}
|
||||
writer = wprisma.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wprisma.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for TypeORM format", label)
|
||||
}
|
||||
writer = wtypeorm.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wtypeorm.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
|
||||
case "sqlite", "sqlite3":
|
||||
writer = wsqlite.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wsqlite.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
|
||||
case "pgsql":
|
||||
writerOpts := &writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema}
|
||||
writerOpts := newWriterOptions(filePath, "", flattenSchema, "", false)
|
||||
if connString != "" {
|
||||
writerOpts.Metadata = map[string]interface{}{
|
||||
"connection_string": connString,
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -160,3 +161,38 @@ func TestRunMerge_FromListMissingSourceType(t *testing.T) {
|
||||
t.Error("expected error when neither --source-path nor --from-list is provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMerge_PgsqlOutputRejectsColumnTypeConflict(t *testing.T) {
|
||||
saved := saveMergeState()
|
||||
defer restoreMergeState(saved)
|
||||
|
||||
dir := t.TempDir()
|
||||
targetFile := filepath.Join(dir, "target.json")
|
||||
sourceFile := filepath.Join(dir, "source.json")
|
||||
writeTestJSONWithSingleColumnType(t, targetFile, "users", "integer")
|
||||
writeTestJSONWithSingleColumnType(t, sourceFile, "users", "uuid")
|
||||
|
||||
mergeTargetType = "json"
|
||||
mergeTargetPath = targetFile
|
||||
mergeTargetConn = ""
|
||||
mergeSourceType = "json"
|
||||
mergeSourcePath = sourceFile
|
||||
mergeSourceConn = ""
|
||||
mergeFromList = nil
|
||||
mergeOutputType = "pgsql"
|
||||
mergeOutputPath = ""
|
||||
mergeOutputConn = "postgres://relspec:secret@localhost/testdb"
|
||||
mergeSkipTables = ""
|
||||
mergeReportPath = ""
|
||||
|
||||
err := runMerge(nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected pgsql output merge to fail on column type conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "column type conflicts detected") {
|
||||
t.Fatalf("expected conflict summary in error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "public.users.id") {
|
||||
t.Fatalf("expected conflicting column path in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
func newReaderOptions(filePath, connString string) *readers.ReaderOptions {
|
||||
return &readers.ReaderOptions{
|
||||
FilePath: filePath,
|
||||
ConnectionString: connString,
|
||||
Prisma7: prisma7,
|
||||
}
|
||||
}
|
||||
|
||||
func newWriterOptions(outputPath, packageName string, flattenSchema bool, nullableTypes string, continueOnError bool) *writers.WriterOptions {
|
||||
return &writers.WriterOptions{
|
||||
OutputPath: outputPath,
|
||||
PackageName: packageName,
|
||||
FlattenSchema: flattenSchema,
|
||||
NullableTypes: nullableTypes,
|
||||
Prisma7: prisma7,
|
||||
ContinueOnError: continueOnError,
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ var (
|
||||
// Version information, set via ldflags during build
|
||||
version = "dev"
|
||||
buildDate = "unknown"
|
||||
prisma7 bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -68,4 +69,5 @@ func init() {
|
||||
rootCmd.AddCommand(mergeCmd)
|
||||
rootCmd.AddCommand(splitCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.PersistentFlags().BoolVar(&prisma7, "prisma7", false, "Use Prisma 7 generator conventions when reading/writing Prisma schemas")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ var (
|
||||
splitDatabaseName string
|
||||
splitExcludeSchema string
|
||||
splitExcludeTables string
|
||||
splitNullableTypes string
|
||||
)
|
||||
|
||||
var splitCmd = &cobra.Command{
|
||||
@@ -110,6 +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 'sqltypes'")
|
||||
|
||||
err := splitCmd.MarkFlagRequired("from")
|
||||
if err != nil {
|
||||
@@ -185,6 +187,8 @@ func runSplit(cmd *cobra.Command, args []string) error {
|
||||
splitPackageName,
|
||||
"", // no schema filter for split
|
||||
false, // no flatten-schema for split
|
||||
splitNullableTypes,
|
||||
false, // no continue-on-error for split
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write output: %w", err)
|
||||
|
||||
@@ -71,6 +71,40 @@ func writeTestJSON(t *testing.T, path string, tableNames []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestJSONWithSingleColumnType(t *testing.T, path, tableName, columnType string) {
|
||||
t.Helper()
|
||||
|
||||
db := minimalDatabase{
|
||||
Name: "test_db",
|
||||
Schemas: []minimalSchema{{
|
||||
Name: "public",
|
||||
Tables: []minimalTable{{
|
||||
Name: tableName,
|
||||
Schema: "public",
|
||||
Columns: map[string]minimalColumn{
|
||||
"id": {
|
||||
Name: "id",
|
||||
Table: tableName,
|
||||
Schema: "public",
|
||||
Type: columnType,
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
AutoIncrement: true,
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(db)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal test JSON: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatalf("failed to write test file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// convertState captures and restores all convert global vars.
|
||||
type convertState struct {
|
||||
sourceType string
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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,19 +1,19 @@
|
||||
module git.warky.dev/wdevs/relspecgo
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/gdamore/tcell/v2 v2.8.1
|
||||
github.com/gdamore/tcell/v2 v2.13.9
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/microsoft/go-mssqldb v1.9.6
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/microsoft/go-mssqldb v1.10.0
|
||||
github.com/rivo/tview v0.42.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/uptrace/bun v1.2.16
|
||||
golang.org/x/text v0.31.0
|
||||
github.com/uptrace/bun v1.2.18
|
||||
golang.org/x/text v0.37.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.44.3
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -27,9 +27,8 @@ require (
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||
@@ -41,11 +40,11 @@ require (
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/term v0.43.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -19,15 +19,14 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
|
||||
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/gdamore/tcell/v2 v2.13.9 h1:uI5l3DYPcFvHINKlGft+en23evOKL+dwtD21QR8ejVA=
|
||||
github.com/gdamore/tcell/v2 v2.13.9/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -40,8 +39,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -52,14 +51,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw=
|
||||
github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
|
||||
github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
@@ -73,8 +70,6 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
@@ -95,8 +90,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
|
||||
github.com/uptrace/bun v1.2.16 h1:QlObi6ZIK5Ao7kAALnh91HWYNZUBbVwye52fmlQM9kc=
|
||||
github.com/uptrace/bun v1.2.16/go.mod h1:jMoNg2n56ckaawi/O/J92BHaECmrz6IRjuMWqlMaMTM=
|
||||
github.com/uptrace/bun v1.2.18 h1:3HnRcMfS6OBPMG1eSOzlbFJ/X/AyMEJb7rMxE6VQvDU=
|
||||
github.com/uptrace/bun v1.2.18/go.mod h1:wNltaKJk4JtOt4SG5I5zmA7v0/Mzjh1+/S906Rayd3Y=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
@@ -105,83 +100,49 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
@@ -189,30 +150,30 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
||||
pkgname=relspec
|
||||
pkgver=1.0.44
|
||||
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,5 +1,5 @@
|
||||
Name: relspec
|
||||
Version: 1.0.44
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package mariadb
|
||||
|
||||
import "strings"
|
||||
|
||||
// MariaDBToCanonicalTypes maps MariaDB/MySQL type names to canonical types.
|
||||
var MariaDBToCanonicalTypes = map[string]string{
|
||||
// Integer types
|
||||
"tinyint": "int8",
|
||||
"smallint": "int16",
|
||||
"mediumint": "int",
|
||||
"int": "int",
|
||||
"integer": "int",
|
||||
"int2": "int16",
|
||||
"int4": "int",
|
||||
"int8": "int64",
|
||||
"bigint": "int64",
|
||||
// Boolean (TINYINT(1) alias)
|
||||
"boolean": "bool",
|
||||
"bool": "bool",
|
||||
"bit": "bool",
|
||||
// Float types
|
||||
"float": "float32",
|
||||
"double": "float64",
|
||||
"real": "float64",
|
||||
"double precision": "float64",
|
||||
// Decimal types
|
||||
"decimal": "decimal",
|
||||
"numeric": "decimal",
|
||||
"dec": "decimal",
|
||||
"fixed": "decimal",
|
||||
// String types
|
||||
"char": "string",
|
||||
"character": "string",
|
||||
"varchar": "string",
|
||||
"nchar": "string",
|
||||
"nvarchar": "string",
|
||||
"tinytext": "text",
|
||||
"text": "text",
|
||||
"mediumtext": "text",
|
||||
"longtext": "text",
|
||||
// Binary/blob types
|
||||
"binary": "bytea",
|
||||
"varbinary": "bytea",
|
||||
"tinyblob": "bytea",
|
||||
"blob": "bytea",
|
||||
"mediumblob": "bytea",
|
||||
"longblob": "bytea",
|
||||
// Date/time types
|
||||
"date": "date",
|
||||
"time": "time",
|
||||
"datetime": "timestamp",
|
||||
"timestamp": "timestamp",
|
||||
"year": "int",
|
||||
// Other types
|
||||
"json": "json",
|
||||
"enum": "string",
|
||||
"set": "string",
|
||||
"uuid": "uuid",
|
||||
}
|
||||
|
||||
// CanonicalToMariaDBTypes maps canonical types to MariaDB/MySQL types.
|
||||
var CanonicalToMariaDBTypes = map[string]string{
|
||||
"bool": "TINYINT(1)",
|
||||
"int8": "TINYINT",
|
||||
"int16": "SMALLINT",
|
||||
"int": "INT",
|
||||
"int32": "INT",
|
||||
"int64": "BIGINT",
|
||||
"uint": "INT UNSIGNED",
|
||||
"uint8": "TINYINT UNSIGNED",
|
||||
"uint16": "SMALLINT UNSIGNED",
|
||||
"uint32": "INT UNSIGNED",
|
||||
"uint64": "BIGINT UNSIGNED",
|
||||
"float32": "FLOAT",
|
||||
"float64": "DOUBLE",
|
||||
"decimal": "DECIMAL",
|
||||
"string": "VARCHAR(255)",
|
||||
"text": "TEXT",
|
||||
"date": "DATE",
|
||||
"time": "TIME",
|
||||
"timestamp": "DATETIME",
|
||||
"timestamptz": "DATETIME",
|
||||
"uuid": "CHAR(36)",
|
||||
"json": "JSON",
|
||||
"jsonb": "JSON",
|
||||
"bytea": "BLOB",
|
||||
}
|
||||
|
||||
// MariaDBTypeSynonyms maps MariaDB/MySQL type aliases to their canonical MariaDB name.
|
||||
var MariaDBTypeSynonyms = map[string]string{
|
||||
"integer": "int",
|
||||
"int2": "smallint",
|
||||
"int4": "int",
|
||||
"int8": "bigint",
|
||||
"double precision": "double",
|
||||
"character": "char",
|
||||
"dec": "decimal",
|
||||
"fixed": "decimal",
|
||||
"numeric": "decimal",
|
||||
"boolean": "tinyint",
|
||||
"bool": "tinyint",
|
||||
}
|
||||
|
||||
// NormalizeMariaDBType maps a MariaDB/MySQL base type (no dimension parameters)
|
||||
// to its canonical MariaDB form. Unknown types are returned as-is (lowercased).
|
||||
func NormalizeMariaDBType(baseType string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(baseType))
|
||||
if canonical, ok := MariaDBTypeSynonyms[lower]; ok {
|
||||
return canonical
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
// ConvertMariaDBToCanonical converts a MariaDB/MySQL type name to the canonical type.
|
||||
// Strips dimension parameters and normalizes aliases. Defaults to "string".
|
||||
func ConvertMariaDBToCanonical(mariadbType string) string {
|
||||
base := strings.ToLower(strings.TrimSpace(mariadbType))
|
||||
if idx := strings.Index(base, "("); idx >= 0 {
|
||||
base = strings.TrimSpace(base[:idx])
|
||||
}
|
||||
|
||||
if canonical, ok := MariaDBToCanonicalTypes[base]; ok {
|
||||
return canonical
|
||||
}
|
||||
|
||||
// Prefix match for composite types (e.g., "unsigned bigint")
|
||||
for key, canonical := range MariaDBToCanonicalTypes {
|
||||
if strings.HasPrefix(base, key) {
|
||||
return canonical
|
||||
}
|
||||
}
|
||||
|
||||
return "string"
|
||||
}
|
||||
|
||||
// ConvertCanonicalToMariaDB converts a canonical type to a MariaDB/MySQL type.
|
||||
// Defaults to VARCHAR(255) for unrecognised types.
|
||||
func ConvertCanonicalToMariaDB(canonicalType string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(canonicalType))
|
||||
if idx := strings.Index(lower, "("); idx >= 0 {
|
||||
lower = strings.TrimSpace(lower[:idx])
|
||||
}
|
||||
|
||||
if mariadbType, ok := CanonicalToMariaDBTypes[lower]; ok {
|
||||
return mariadbType
|
||||
}
|
||||
|
||||
// Prefix fallback
|
||||
for canonical, mariadb := range CanonicalToMariaDBTypes {
|
||||
if strings.HasPrefix(lower, canonical) {
|
||||
return mariadb
|
||||
}
|
||||
}
|
||||
|
||||
return "VARCHAR(255)"
|
||||
}
|
||||
+126
-1
@@ -5,9 +5,11 @@ package merge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
)
|
||||
|
||||
// MergeResult represents the result of a merge operation
|
||||
@@ -22,6 +24,16 @@ type MergeResult struct {
|
||||
EnumsAdded int
|
||||
ViewsAdded int
|
||||
SequencesAdded int
|
||||
TypeConflicts []ColumnTypeConflict
|
||||
}
|
||||
|
||||
// ColumnTypeConflict describes a column that exists in both schemas but with incompatible types.
|
||||
type ColumnTypeConflict struct {
|
||||
Schema string
|
||||
Table string
|
||||
Column string
|
||||
TargetType string
|
||||
SourceType string
|
||||
}
|
||||
|
||||
// MergeOptions contains options for merge operations
|
||||
@@ -146,11 +158,19 @@ func (r *MergeResult) mergeColumns(table *models.Table, srcTable *models.Table)
|
||||
|
||||
// Merge columns
|
||||
for colName, srcCol := range srcTable.Columns {
|
||||
if _, exists := existingColumns[colName]; !exists {
|
||||
if tgtCol, exists := existingColumns[colName]; !exists {
|
||||
// Column doesn't exist, add it
|
||||
newCol := cloneColumn(srcCol)
|
||||
table.Columns[colName] = newCol
|
||||
r.ColumnsAdded++
|
||||
} else if columnTypeConflict(tgtCol, srcCol) {
|
||||
r.TypeConflicts = append(r.TypeConflicts, ColumnTypeConflict{
|
||||
Schema: firstNonEmpty(table.Schema, srcTable.Schema, srcCol.Schema),
|
||||
Table: firstNonEmpty(table.Name, srcTable.Name, srcCol.Table),
|
||||
Column: firstNonEmpty(tgtCol.Name, srcCol.Name, colName),
|
||||
TargetType: describeColumnType(tgtCol),
|
||||
SourceType: describeColumnType(srcCol),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,6 +446,78 @@ func cloneColumn(col *models.Column) *models.Column {
|
||||
return newCol
|
||||
}
|
||||
|
||||
func columnTypeConflict(target, source *models.Column) bool {
|
||||
if target == nil || source == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
tType, tLen, tPrec, tScale := extractTypeParts(target)
|
||||
sType, sLen, sPrec, sScale := extractTypeParts(source)
|
||||
|
||||
return tType != sType || tLen != sLen || tPrec != sPrec || tScale != sScale
|
||||
}
|
||||
|
||||
// extractTypeParts returns the canonical base type and dimensions for a column,
|
||||
// handling the case where dimensions are embedded in the type string (e.g. "char(2)")
|
||||
// rather than stored in the separate Length/Precision/Scale fields.
|
||||
func extractTypeParts(col *models.Column) (baseType string, length, precision, scale int) {
|
||||
typeName := strings.ToLower(strings.TrimSpace(col.Type))
|
||||
length, precision, scale = col.Length, col.Precision, col.Scale
|
||||
|
||||
if idx := strings.Index(typeName, "("); idx >= 0 {
|
||||
inner := strings.TrimRight(strings.TrimSpace(typeName[idx+1:]), ")")
|
||||
typeName = strings.TrimSpace(typeName[:idx])
|
||||
parts := strings.Split(inner, ",")
|
||||
if len(parts) == 2 {
|
||||
if p, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil && p > 0 && precision == 0 {
|
||||
precision = p
|
||||
}
|
||||
if s, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil && s > 0 && scale == 0 {
|
||||
scale = s
|
||||
}
|
||||
} else if len(parts) == 1 {
|
||||
if l, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil && l > 0 && length == 0 && precision == 0 {
|
||||
length = l
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typeName = pgsql.NormalizePGType(typeName)
|
||||
|
||||
return typeName, length, precision, scale
|
||||
}
|
||||
|
||||
func describeColumnType(col *models.Column) string {
|
||||
if col == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
typeName := strings.TrimSpace(col.Type)
|
||||
if typeName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch {
|
||||
case col.Precision > 0 && col.Scale > 0:
|
||||
return fmt.Sprintf("%s(%d,%d)", typeName, col.Precision, col.Scale)
|
||||
case col.Precision > 0:
|
||||
return fmt.Sprintf("%s(%d)", typeName, col.Precision)
|
||||
case col.Length > 0:
|
||||
return fmt.Sprintf("%s(%d)", typeName, col.Length)
|
||||
default:
|
||||
return typeName
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneConstraint(constraint *models.Constraint) *models.Constraint {
|
||||
if constraint == nil {
|
||||
return nil
|
||||
@@ -609,6 +701,7 @@ func GetMergeSummary(result *MergeResult) string {
|
||||
fmt.Sprintf("Enums added: %d", result.EnumsAdded),
|
||||
fmt.Sprintf("Relations added: %d", result.RelationsAdded),
|
||||
fmt.Sprintf("Domains added: %d", result.DomainsAdded),
|
||||
fmt.Sprintf("Type conflicts: %d", len(result.TypeConflicts)),
|
||||
}
|
||||
|
||||
totalAdded := result.SchemasAdded + result.TablesAdded + result.ColumnsAdded +
|
||||
@@ -625,3 +718,35 @@ func GetMergeSummary(result *MergeResult) string {
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// GetColumnTypeConflictSummary returns a short, human-readable conflict summary.
|
||||
func GetColumnTypeConflictSummary(result *MergeResult, limit int) string {
|
||||
if result == nil || len(result.TypeConflicts) == 0 {
|
||||
return ""
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = len(result.TypeConflicts)
|
||||
}
|
||||
|
||||
lines := make([]string, 0, min(limit, len(result.TypeConflicts))+1)
|
||||
lines = append(lines, "column type conflicts detected:")
|
||||
for i, conflict := range result.TypeConflicts {
|
||||
if i >= limit {
|
||||
break
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" - %s.%s.%s: target=%s source=%s",
|
||||
conflict.Schema, conflict.Table, conflict.Column, conflict.TargetType, conflict.SourceType))
|
||||
}
|
||||
if len(result.TypeConflicts) > limit {
|
||||
lines = append(lines, fmt.Sprintf(" ... and %d more", len(result.TypeConflicts)-limit))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package merge
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
@@ -140,6 +141,61 @@ func TestMergeColumns_NewColumn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeColumns_TypeConflictIsDetected(t *testing.T) {
|
||||
target := &models.Database{
|
||||
Schemas: []*models.Schema{
|
||||
{
|
||||
Name: "public",
|
||||
Tables: []*models.Table{
|
||||
{
|
||||
Name: "users",
|
||||
Schema: "public",
|
||||
Columns: map[string]*models.Column{
|
||||
"email": {Name: "email", Type: "varchar", Length: 255},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
source := &models.Database{
|
||||
Schemas: []*models.Schema{
|
||||
{
|
||||
Name: "public",
|
||||
Tables: []*models.Table{
|
||||
{
|
||||
Name: "users",
|
||||
Schema: "public",
|
||||
Columns: map[string]*models.Column{
|
||||
"email": {Name: "email", Type: "text"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := MergeDatabases(target, source, nil)
|
||||
|
||||
if len(result.TypeConflicts) != 1 {
|
||||
t.Fatalf("Expected 1 type conflict, got %d", len(result.TypeConflicts))
|
||||
}
|
||||
conflict := result.TypeConflicts[0]
|
||||
if conflict.Schema != "public" || conflict.Table != "users" || conflict.Column != "email" {
|
||||
t.Fatalf("Unexpected conflict location: %+v", conflict)
|
||||
}
|
||||
if conflict.TargetType != "varchar(255)" {
|
||||
t.Fatalf("Expected target type varchar(255), got %q", conflict.TargetType)
|
||||
}
|
||||
if conflict.SourceType != "text" {
|
||||
t.Fatalf("Expected source type text, got %q", conflict.SourceType)
|
||||
}
|
||||
|
||||
if got := target.Schemas[0].Tables[0].Columns["email"].Type; got != "varchar" {
|
||||
t.Fatalf("Expected target column type to remain unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConstraints_NewConstraint(t *testing.T) {
|
||||
target := &models.Database{
|
||||
Schemas: []*models.Schema{
|
||||
@@ -509,6 +565,9 @@ func TestGetMergeSummary(t *testing.T) {
|
||||
ConstraintsAdded: 3,
|
||||
IndexesAdded: 2,
|
||||
ViewsAdded: 1,
|
||||
TypeConflicts: []ColumnTypeConflict{
|
||||
{Schema: "public", Table: "users", Column: "email", TargetType: "varchar(255)", SourceType: "text"},
|
||||
},
|
||||
}
|
||||
|
||||
summary := GetMergeSummary(result)
|
||||
@@ -518,6 +577,9 @@ func TestGetMergeSummary(t *testing.T) {
|
||||
if len(summary) < 50 {
|
||||
t.Errorf("Summary seems too short: %s", summary)
|
||||
}
|
||||
if !strings.Contains(summary, "Type conflicts: 1") {
|
||||
t.Errorf("Expected type conflict count in summary, got: %s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMergeSummary_Nil(t *testing.T) {
|
||||
|
||||
+94
-50
@@ -2,32 +2,73 @@ package mssql
|
||||
|
||||
import "strings"
|
||||
|
||||
// CanonicalToMSSQLTypes maps canonical types to MSSQL types
|
||||
// CanonicalToMSSQLTypes maps canonical types to MSSQL types.
|
||||
// Accepts both Go canonical names ("int", "string") and SQL canonical names
|
||||
// ("integer", "varchar") so the writer handles input from any reader.
|
||||
var CanonicalToMSSQLTypes = map[string]string{
|
||||
"bool": "BIT",
|
||||
"int8": "TINYINT",
|
||||
"int16": "SMALLINT",
|
||||
"int": "INT",
|
||||
"int32": "INT",
|
||||
"int64": "BIGINT",
|
||||
"uint": "BIGINT",
|
||||
"uint8": "SMALLINT",
|
||||
"uint16": "INT",
|
||||
"uint32": "BIGINT",
|
||||
"uint64": "BIGINT",
|
||||
"float32": "REAL",
|
||||
"float64": "FLOAT",
|
||||
"decimal": "NUMERIC",
|
||||
"string": "NVARCHAR(255)",
|
||||
"text": "NVARCHAR(MAX)",
|
||||
// Boolean — Go and SQL canonical
|
||||
"bool": "BIT",
|
||||
"boolean": "BIT",
|
||||
// Integer — Go canonical
|
||||
"int8": "TINYINT",
|
||||
"int16": "SMALLINT",
|
||||
"int": "INT",
|
||||
"int32": "INT",
|
||||
"int64": "BIGINT",
|
||||
"uint": "BIGINT",
|
||||
"uint8": "TINYINT",
|
||||
"uint16": "SMALLINT",
|
||||
"uint32": "BIGINT",
|
||||
"uint64": "BIGINT",
|
||||
// Integer — SQL canonical (serial types map to base integer; IDENTITY is set via AutoIncrement)
|
||||
"integer": "INT",
|
||||
"smallint": "SMALLINT",
|
||||
"bigint": "BIGINT",
|
||||
"tinyint": "TINYINT",
|
||||
"serial": "INT",
|
||||
"smallserial": "SMALLINT",
|
||||
"bigserial": "BIGINT",
|
||||
// Float — Go canonical
|
||||
"float32": "REAL",
|
||||
"float64": "FLOAT",
|
||||
// Float — SQL canonical
|
||||
"real": "REAL",
|
||||
"double precision": "FLOAT",
|
||||
"double": "FLOAT",
|
||||
// Decimal/numeric
|
||||
"decimal": "NUMERIC",
|
||||
"numeric": "NUMERIC",
|
||||
"money": "MONEY",
|
||||
// String — Go canonical
|
||||
"string": "NVARCHAR(255)",
|
||||
"text": "NVARCHAR(MAX)",
|
||||
// String — SQL canonical
|
||||
"varchar": "NVARCHAR(255)",
|
||||
"char": "NCHAR",
|
||||
"nvarchar": "NVARCHAR(255)",
|
||||
"nchar": "NCHAR",
|
||||
"citext": "NVARCHAR(MAX)",
|
||||
// Date/time
|
||||
"date": "DATE",
|
||||
"time": "TIME",
|
||||
"timetz": "DATETIMEOFFSET",
|
||||
"timestamp": "DATETIME2",
|
||||
"timestamptz": "DATETIMEOFFSET",
|
||||
"uuid": "UNIQUEIDENTIFIER",
|
||||
"json": "NVARCHAR(MAX)",
|
||||
"jsonb": "NVARCHAR(MAX)",
|
||||
"bytea": "VARBINARY(MAX)",
|
||||
"datetime": "DATETIME2",
|
||||
"interval": "NVARCHAR(50)",
|
||||
// UUID
|
||||
"uuid": "UNIQUEIDENTIFIER",
|
||||
// JSON — MSSQL has no native JSON type; stored as NVARCHAR(MAX)
|
||||
"json": "NVARCHAR(MAX)",
|
||||
"jsonb": "NVARCHAR(MAX)",
|
||||
// Binary
|
||||
"bytea": "VARBINARY(MAX)",
|
||||
"blob": "VARBINARY(MAX)",
|
||||
// Network/geo types — no MSSQL native equivalent
|
||||
"xml": "XML",
|
||||
"inet": "NVARCHAR(45)",
|
||||
"cidr": "NVARCHAR(43)",
|
||||
"macaddr": "NVARCHAR(17)",
|
||||
}
|
||||
|
||||
// MSSQLToCanonicalTypes maps MSSQL types to canonical types
|
||||
@@ -68,47 +109,50 @@ var MSSQLToCanonicalTypes = map[string]string{
|
||||
"geometry": "string",
|
||||
}
|
||||
|
||||
// ConvertCanonicalToMSSQL converts a canonical type to MSSQL type
|
||||
// MSSQLTypeSynonyms maps MSSQL type aliases to their canonical MSSQL name.
|
||||
var MSSQLTypeSynonyms = map[string]string{
|
||||
"integer": "int",
|
||||
"dec": "decimal",
|
||||
"float(n)": "float",
|
||||
}
|
||||
|
||||
// NormalizeMSSQLType maps an MSSQL base type (no dimension parameters) to its
|
||||
// canonical MSSQL form. Unknown types are returned as-is (lowercased).
|
||||
func NormalizeMSSQLType(baseType string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(baseType))
|
||||
if canonical, ok := MSSQLTypeSynonyms[lower]; ok {
|
||||
return canonical
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
// ConvertCanonicalToMSSQL converts a canonical type (Go or SQL) to an MSSQL type.
|
||||
// Strips dimension parameters before lookup. Defaults to NVARCHAR(255) for unknown types.
|
||||
func ConvertCanonicalToMSSQL(canonicalType string) string {
|
||||
// Check direct mapping
|
||||
if mssqlType, exists := CanonicalToMSSQLTypes[strings.ToLower(canonicalType)]; exists {
|
||||
base := strings.ToLower(strings.TrimSpace(canonicalType))
|
||||
if idx := strings.Index(base, "("); idx >= 0 {
|
||||
base = strings.TrimSpace(base[:idx])
|
||||
}
|
||||
base = strings.TrimSuffix(base, "[]")
|
||||
|
||||
if mssqlType, exists := CanonicalToMSSQLTypes[base]; exists {
|
||||
return mssqlType
|
||||
}
|
||||
|
||||
// Try to find by prefix
|
||||
lowerType := strings.ToLower(canonicalType)
|
||||
for canonical, mssql := range CanonicalToMSSQLTypes {
|
||||
if strings.HasPrefix(lowerType, canonical) {
|
||||
return mssql
|
||||
}
|
||||
}
|
||||
|
||||
// Default to NVARCHAR
|
||||
return "NVARCHAR(255)"
|
||||
}
|
||||
|
||||
// ConvertMSSQLToCanonical converts an MSSQL type to canonical type
|
||||
// ConvertMSSQLToCanonical converts an MSSQL type to the canonical type.
|
||||
// Strips dimension parameters before lookup. Defaults to "string" for unknown types.
|
||||
func ConvertMSSQLToCanonical(mssqlType string) string {
|
||||
// Extract base type (remove parentheses and parameters)
|
||||
baseType := mssqlType
|
||||
if idx := strings.Index(baseType, "("); idx != -1 {
|
||||
baseType = baseType[:idx]
|
||||
base := strings.ToLower(strings.TrimSpace(mssqlType))
|
||||
if idx := strings.Index(base, "("); idx >= 0 {
|
||||
base = strings.TrimSpace(base[:idx])
|
||||
}
|
||||
baseType = strings.TrimSpace(baseType)
|
||||
|
||||
// Check direct mapping
|
||||
if canonicalType, exists := MSSQLToCanonicalTypes[strings.ToLower(baseType)]; exists {
|
||||
if canonicalType, exists := MSSQLToCanonicalTypes[base]; exists {
|
||||
return canonicalType
|
||||
}
|
||||
|
||||
// Try to find by prefix
|
||||
lowerType := strings.ToLower(baseType)
|
||||
for mssql, canonical := range MSSQLToCanonicalTypes {
|
||||
if strings.HasPrefix(lowerType, mssql) {
|
||||
return canonical
|
||||
}
|
||||
}
|
||||
|
||||
// Default to string
|
||||
return "string"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ var GoToStdTypes = map[string]string{
|
||||
"sqldate": "date",
|
||||
"sqltime": "time",
|
||||
"sqltimestamp": "timestamp",
|
||||
"time.Time": "timestamp",
|
||||
}
|
||||
|
||||
var GoToPGSQLTypes = map[string]string{
|
||||
@@ -90,6 +91,7 @@ var GoToPGSQLTypes = map[string]string{
|
||||
"sqldate": "date",
|
||||
"sqltime": "time",
|
||||
"sqltimestamp": "timestamp",
|
||||
"time.Time": "timestamp",
|
||||
"citext": "citext",
|
||||
}
|
||||
|
||||
@@ -135,6 +137,62 @@ func ConvertSQLType(anytype string) string {
|
||||
return anytype
|
||||
}
|
||||
|
||||
// PGTypeCanonical maps PostgreSQL type aliases and synonyms to their canonical base name.
|
||||
// Input should be a base type (no dimension parameters, lowercase).
|
||||
var PGTypeCanonical = map[string]string{
|
||||
// integer aliases
|
||||
"int": "integer",
|
||||
"int4": "integer",
|
||||
"int2": "smallint",
|
||||
"int8": "bigint",
|
||||
// float aliases
|
||||
"float4": "real",
|
||||
"float8": "double precision",
|
||||
// bool alias
|
||||
"bool": "boolean",
|
||||
// char aliases
|
||||
"character": "char",
|
||||
"character varying": "varchar",
|
||||
"bpchar": "char",
|
||||
// timestamp aliases
|
||||
"timestamp without time zone": "timestamp",
|
||||
"timestamp with time zone": "timestamptz",
|
||||
// time aliases
|
||||
"time without time zone": "time",
|
||||
"time with time zone": "timetz",
|
||||
// decimal alias
|
||||
"decimal": "numeric",
|
||||
}
|
||||
|
||||
// knownPGBaseTypes is the set of canonical PostgreSQL base types (no aliases).
|
||||
var knownPGBaseTypes = map[string]struct{}{
|
||||
"integer": {}, "bigint": {}, "smallint": {},
|
||||
"serial": {}, "bigserial": {}, "smallserial": {},
|
||||
"numeric": {}, "real": {}, "double precision": {}, "money": {},
|
||||
"varchar": {}, "char": {}, "text": {}, "citext": {},
|
||||
"boolean": {},
|
||||
"date": {}, "time": {}, "timetz": {}, "timestamp": {}, "timestamptz": {}, "interval": {},
|
||||
"uuid": {}, "json": {}, "jsonb": {}, "bytea": {},
|
||||
"inet": {}, "cidr": {}, "macaddr": {}, "xml": {},
|
||||
}
|
||||
|
||||
// NormalizePGType maps a PostgreSQL base type (no dimension parameters) to its
|
||||
// canonical form. Unknown types are returned as-is (lowercased).
|
||||
func NormalizePGType(baseType string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(baseType))
|
||||
if canonical, ok := PGTypeCanonical[lower]; ok {
|
||||
return canonical
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
// IsKnownPGBaseType reports whether the given name (after NormalizePGType) is a
|
||||
// recognized built-in PostgreSQL type. Custom types (e.g. vector, postgis) return false.
|
||||
func IsKnownPGBaseType(baseType string) bool {
|
||||
_, ok := knownPGBaseTypes[strings.ToLower(strings.TrimSpace(baseType))]
|
||||
return ok
|
||||
}
|
||||
|
||||
func IsGoType(pTypeName string) bool {
|
||||
for k := range GoToStdTypes {
|
||||
if strings.EqualFold(pTypeName, k) {
|
||||
|
||||
@@ -145,6 +145,24 @@ var postgresTypeAliases = map[string]string{
|
||||
"bool": "boolean",
|
||||
}
|
||||
|
||||
var postgresEquivalentBaseTypes = map[string]string{
|
||||
"character varying": "varchar",
|
||||
"character": "char",
|
||||
"timestamp without time zone": "timestamp",
|
||||
"timestamp with time zone": "timestamptz",
|
||||
"time without time zone": "time",
|
||||
"time with time zone": "timetz",
|
||||
}
|
||||
|
||||
var postgresEquivalentBaseTypeVariants = map[string][]string{
|
||||
"varchar": {"varchar", "character varying"},
|
||||
"char": {"char", "character"},
|
||||
"timestamp": {"timestamp", "timestamp without time zone"},
|
||||
"timestamptz": {"timestamptz", "timestamp with time zone"},
|
||||
"time": {"time", "time without time zone"},
|
||||
"timetz": {"timetz", "time with time zone"},
|
||||
}
|
||||
|
||||
// GetPostgresBaseTypes returns a sorted-ish stable list of registered base type names.
|
||||
func GetPostgresBaseTypes() []string {
|
||||
result := make([]string, 0, len(postgresBaseTypes))
|
||||
@@ -212,6 +230,86 @@ func CanonicalizeBaseType(baseType string) string {
|
||||
return base
|
||||
}
|
||||
|
||||
// EquivalentBaseType resolves broader SQL-equivalent spellings to a common comparable form.
|
||||
func EquivalentBaseType(baseType string) string {
|
||||
base := CanonicalizeBaseType(baseType)
|
||||
if equivalent, ok := postgresEquivalentBaseTypes[base]; ok {
|
||||
return equivalent
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// NormalizeEquivalentSQLType returns a normalized SQL type string suitable for equality checks.
|
||||
// Equivalent spellings such as "character varying(255)" and "varchar(255)" normalize identically.
|
||||
func NormalizeEquivalentSQLType(sqlType string) string {
|
||||
t := normalizeTypeToken(sqlType)
|
||||
if t == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
arrayDepth := 0
|
||||
for strings.HasSuffix(t, "[]") {
|
||||
arrayDepth++
|
||||
t = strings.TrimSpace(strings.TrimSuffix(t, "[]"))
|
||||
}
|
||||
|
||||
modifier := ""
|
||||
if idx := strings.Index(t, "("); idx >= 0 {
|
||||
modifier = strings.TrimSpace(t[idx:])
|
||||
t = strings.TrimSpace(t[:idx])
|
||||
}
|
||||
|
||||
base := EquivalentBaseType(t)
|
||||
normalized := base + modifier
|
||||
for i := 0; i < arrayDepth; i++ {
|
||||
normalized += "[]"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// EquivalentSQLTypeVariants returns equivalent PostgreSQL spellings for a SQL type.
|
||||
// Examples:
|
||||
// - varchar(255) -> ["varchar(255)", "character varying(255)"]
|
||||
// - timestamptz -> ["timestamptz", "timestamp with time zone"]
|
||||
func EquivalentSQLTypeVariants(sqlType string) []string {
|
||||
t := normalizeTypeToken(sqlType)
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
arrayDepth := 0
|
||||
for strings.HasSuffix(t, "[]") {
|
||||
arrayDepth++
|
||||
t = strings.TrimSpace(strings.TrimSuffix(t, "[]"))
|
||||
}
|
||||
|
||||
modifier := ""
|
||||
if idx := strings.Index(t, "("); idx >= 0 {
|
||||
modifier = strings.TrimSpace(t[idx:])
|
||||
t = strings.TrimSpace(t[:idx])
|
||||
}
|
||||
|
||||
base := EquivalentBaseType(t)
|
||||
bases := postgresEquivalentBaseTypeVariants[base]
|
||||
if len(bases) == 0 {
|
||||
bases = []string{base}
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(bases))
|
||||
result := make([]string, 0, len(bases))
|
||||
for _, variantBase := range bases {
|
||||
variant := variantBase + modifier
|
||||
for i := 0; i < arrayDepth; i++ {
|
||||
variant += "[]"
|
||||
}
|
||||
if !seen[variant] {
|
||||
seen[variant] = true
|
||||
result = append(result, variant)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IsKnownPostgresType reports whether a type (including array forms) exists in the registry.
|
||||
func IsKnownPostgresType(sqlType string) bool {
|
||||
base := CanonicalizeBaseType(ExtractBaseTypeLower(sqlType))
|
||||
|
||||
@@ -97,3 +97,51 @@ func TestPostgresTypeRegistry_TypeParsingAndCapabilities(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEquivalentSQLType(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: "character varying(255)", want: "varchar(255)"},
|
||||
{input: "varchar(255)", want: "varchar(255)"},
|
||||
{input: "timestamp with time zone", want: "timestamptz"},
|
||||
{input: "timestamptz", want: "timestamptz"},
|
||||
{input: "time without time zone", want: "time"},
|
||||
{input: "character varying(255)[]", want: "varchar(255)[]"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := NormalizeEquivalentSQLType(tt.input)
|
||||
if got != tt.want {
|
||||
t.Fatalf("NormalizeEquivalentSQLType(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquivalentSQLTypeVariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{input: "character varying(255)", want: []string{"varchar(255)", "character varying(255)"}},
|
||||
{input: "timestamptz", want: []string{"timestamptz", "timestamp with time zone"}},
|
||||
{input: "text[]", want: []string{"text[]"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := EquivalentSQLTypeVariants(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("EquivalentSQLTypeVariants(%q) len = %d, want %d (%v)", tt.input, len(got), len(tt.want), got)
|
||||
}
|
||||
for i := range tt.want {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("EquivalentSQLTypeVariants(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,19 +677,8 @@ func (r *Reader) extractTableFromGormTag(tag string) (tablename string, schemaNa
|
||||
|
||||
// deriveTableName derives a table name from struct name
|
||||
func (r *Reader) deriveTableName(structName string) string {
|
||||
// Remove "Model" prefix if present
|
||||
name := strings.TrimPrefix(structName, "Model")
|
||||
|
||||
// Convert PascalCase to snake_case
|
||||
var result strings.Builder
|
||||
for i, r := range name {
|
||||
if i > 0 && r >= 'A' && r <= 'Z' {
|
||||
result.WriteRune('_')
|
||||
}
|
||||
result.WriteRune(r)
|
||||
}
|
||||
|
||||
return strings.ToLower(result.String())
|
||||
// Remove "Model" prefix if present, use the name as-is without transformation
|
||||
return strings.TrimPrefix(structName, "Model")
|
||||
}
|
||||
|
||||
// parseColumn parses a struct field into a Column model
|
||||
|
||||
@@ -252,7 +252,7 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
|
||||
column.AutoIncrement = true
|
||||
column.Default = defaultVal
|
||||
} else {
|
||||
column.Default = defaultVal
|
||||
column.Default = normalizePostgresDefault(defaultVal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,15 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
|
||||
}
|
||||
|
||||
if numPrecision != nil {
|
||||
column.Precision = *numPrecision
|
||||
// For integer and serial types, numeric_precision is a bit-width (32, 64, 16)
|
||||
// not a user-visible column parameter. Only store precision for types where
|
||||
// it represents actual decimal/scale precision (numeric, decimal, float).
|
||||
switch column.Type {
|
||||
case "integer", "bigint", "smallint", "serial", "bigserial", "smallserial":
|
||||
// skip — bit-width, not a column parameter
|
||||
default:
|
||||
column.Precision = *numPrecision
|
||||
}
|
||||
}
|
||||
|
||||
if numScale != nil {
|
||||
@@ -613,3 +621,30 @@ func (r *Reader) parseIndexDefinition(indexName, tableName, schema, indexDef str
|
||||
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// normalizePostgresDefault converts a raw PostgreSQL column_default expression into the
|
||||
// unquoted string value that the model convention expects. PostgreSQL stores string
|
||||
// literal defaults as 'value' or 'value'::type (e.g. '{}'::text[]), while every other
|
||||
// reader stores the bare value so the writer can re-quote it correctly.
|
||||
func normalizePostgresDefault(defaultVal string) string {
|
||||
if !strings.HasPrefix(defaultVal, "'") {
|
||||
return defaultVal
|
||||
}
|
||||
// Decode the SQL string literal: skip the leading quote, unescape '' → ', stop at
|
||||
// the first unescaped closing quote (any trailing ::cast is ignored).
|
||||
rest := defaultVal[1:]
|
||||
var buf strings.Builder
|
||||
for i := 0; i < len(rest); i++ {
|
||||
if rest[i] == '\'' {
|
||||
if i+1 < len(rest) && rest[i+1] == '\'' {
|
||||
buf.WriteByte('\'')
|
||||
i++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
buf.WriteByte(rest[i])
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
+28
-62
@@ -259,12 +259,14 @@ func (r *Reader) close() {
|
||||
}
|
||||
}
|
||||
|
||||
// mapDataType maps PostgreSQL data types while preserving exact type text when available.
|
||||
// mapDataType maps a PostgreSQL data type to its canonical RelSpec name.
|
||||
// For known built-in types, dimensions are stripped from the type string (they are
|
||||
// stored separately in column.Length/Precision/Scale). For custom types (e.g.
|
||||
// vector(1536), postgis geometries), the full formatted type is preserved.
|
||||
func (r *Reader) mapDataType(pgType, udtName, formattedType string, hasNextval bool) string {
|
||||
normalizedPGType := strings.ToLower(strings.TrimSpace(pgType))
|
||||
|
||||
// If the column has a nextval default, it's likely a serial type
|
||||
// Map to the appropriate serial type instead of the base integer type
|
||||
// Detect serial types from nextval defaults before anything else.
|
||||
if hasNextval {
|
||||
switch normalizedPGType {
|
||||
case "integer", "int", "int4":
|
||||
@@ -276,73 +278,38 @@ func (r *Reader) mapDataType(pgType, udtName, formattedType string, hasNextval b
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer the database-provided formatted type; this preserves arrays/custom
|
||||
// types/modifiers like text[], vector(1536), numeric(10,2), etc.
|
||||
if strings.TrimSpace(formattedType) != "" {
|
||||
return formattedType
|
||||
}
|
||||
|
||||
// information_schema reports arrays generically as "ARRAY" with udt_name like "_text".
|
||||
if strings.EqualFold(pgType, "ARRAY") && strings.HasPrefix(udtName, "_") && len(udtName) > 1 {
|
||||
return udtName[1:] + "[]"
|
||||
}
|
||||
|
||||
// Map common PostgreSQL types
|
||||
typeMap := map[string]string{
|
||||
"integer": "integer",
|
||||
"bigint": "bigint",
|
||||
"smallint": "smallint",
|
||||
"int": "integer",
|
||||
"int2": "smallint",
|
||||
"int4": "integer",
|
||||
"int8": "bigint",
|
||||
"serial": "serial",
|
||||
"bigserial": "bigserial",
|
||||
"smallserial": "smallserial",
|
||||
"numeric": "numeric",
|
||||
"decimal": "decimal",
|
||||
"real": "real",
|
||||
"double precision": "double precision",
|
||||
"float4": "real",
|
||||
"float8": "double precision",
|
||||
"money": "money",
|
||||
"character varying": "varchar",
|
||||
"varchar": "varchar",
|
||||
"character": "char",
|
||||
"char": "char",
|
||||
"text": "text",
|
||||
"boolean": "boolean",
|
||||
"bool": "boolean",
|
||||
"date": "date",
|
||||
"time": "time",
|
||||
"time without time zone": "time",
|
||||
"time with time zone": "timetz",
|
||||
"timestamp": "timestamp",
|
||||
"timestamp without time zone": "timestamp",
|
||||
"timestamp with time zone": "timestamptz",
|
||||
"timestamptz": "timestamptz",
|
||||
"interval": "interval",
|
||||
"uuid": "uuid",
|
||||
"json": "json",
|
||||
"jsonb": "jsonb",
|
||||
"bytea": "bytea",
|
||||
"inet": "inet",
|
||||
"cidr": "cidr",
|
||||
"macaddr": "macaddr",
|
||||
"xml": "xml",
|
||||
// Use the database-formatted type when available. For known built-in types, strip
|
||||
// embedded dimensions (they are stored in column.Length/Precision/Scale separately).
|
||||
// For unknown/custom types, keep the full formatted string (e.g. vector(1536)).
|
||||
if strings.TrimSpace(formattedType) != "" {
|
||||
lower := strings.ToLower(strings.TrimSpace(formattedType))
|
||||
isArray := strings.HasSuffix(lower, "[]")
|
||||
base := strings.TrimSuffix(lower, "[]")
|
||||
if idx := strings.Index(base, "("); idx >= 0 {
|
||||
base = strings.TrimSpace(base[:idx])
|
||||
}
|
||||
canonical := pgsql.NormalizePGType(base)
|
||||
if pgsql.IsKnownPGBaseType(canonical) {
|
||||
if isArray {
|
||||
return canonical + "[]"
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
return formattedType
|
||||
}
|
||||
|
||||
// Try mapped type first
|
||||
if mapped, exists := typeMap[normalizedPGType]; exists {
|
||||
return mapped
|
||||
// Fall back to normalizing the information_schema type name directly.
|
||||
canonical := pgsql.NormalizePGType(normalizedPGType)
|
||||
if pgsql.IsKnownPGBaseType(canonical) {
|
||||
return canonical
|
||||
}
|
||||
|
||||
// Use pgsql utilities if available
|
||||
if pgsql.ValidSQLType(pgType) {
|
||||
return pgsql.GetSQLType(pgType)
|
||||
}
|
||||
|
||||
// Return UDT name for custom types (including array fallback when needed)
|
||||
// Return UDT name for custom types.
|
||||
if udtName != "" {
|
||||
if strings.HasPrefix(udtName, "_") && len(udtName) > 1 {
|
||||
return udtName[1:] + "[]"
|
||||
@@ -350,7 +317,6 @@ func (r *Reader) mapDataType(pgType, udtName, formattedType string, hasNextval b
|
||||
return udtName
|
||||
}
|
||||
|
||||
// Default to the original type
|
||||
return pgType
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ func TestMapDataType(t *testing.T) {
|
||||
{"unknown_type", "custom", "", "custom"}, // Should return UDT name
|
||||
{"ARRAY", "_text", "", "text[]"},
|
||||
{"USER-DEFINED", "vector", "vector(1536)", "vector(1536)"},
|
||||
{"character varying", "varchar", "character varying(255)", "character varying(255)"},
|
||||
{"character varying", "varchar", "character varying(255)", "varchar"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -70,6 +70,7 @@ func (r *Reader) ReadTable() (*models.Table, error) {
|
||||
// parsePrisma parses Prisma schema content and returns a Database model
|
||||
func (r *Reader) parsePrisma(content string) (*models.Database, error) {
|
||||
db := models.InitDatabase("database")
|
||||
db.SourceFormat = "prisma"
|
||||
|
||||
if r.options.Metadata != nil {
|
||||
if name, ok := r.options.Metadata["name"].(string); ok {
|
||||
@@ -139,7 +140,7 @@ func (r *Reader) parsePrisma(content string) (*models.Database, error) {
|
||||
case "datasource":
|
||||
r.parseDatasource(blockContent, db)
|
||||
case "generator":
|
||||
// We don't need to do anything with generator blocks
|
||||
r.parseGenerator(blockContent, db)
|
||||
case "model":
|
||||
if currentTable != nil {
|
||||
r.parseModelFields(blockContent, currentTable)
|
||||
@@ -173,10 +174,34 @@ func (r *Reader) parsePrisma(content string) (*models.Database, error) {
|
||||
// Second pass: resolve relationships
|
||||
r.resolveRelationships(schema)
|
||||
|
||||
if db.SourceFormat == "prisma" && r.options != nil && r.options.Prisma7 {
|
||||
db.SourceFormat = "prisma7"
|
||||
}
|
||||
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (r *Reader) parseGenerator(lines []string, db *models.Database) {
|
||||
providerRegex := regexp.MustCompile(`provider\s*=\s*"([^"]+)"`)
|
||||
|
||||
for _, line := range lines {
|
||||
if matches := providerRegex.FindStringSubmatch(line); matches != nil {
|
||||
switch matches[1] {
|
||||
case "prisma-client":
|
||||
db.SourceFormat = "prisma7"
|
||||
default:
|
||||
db.SourceFormat = "prisma"
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if r.options != nil && r.options.Prisma7 {
|
||||
db.SourceFormat = "prisma7"
|
||||
}
|
||||
}
|
||||
|
||||
// parseDatasource extracts database type from datasource block
|
||||
func (r *Reader) parseDatasource(lines []string, db *models.Database) {
|
||||
providerRegex := regexp.MustCompile(`provider\s*=\s*"?(\w+)"?`)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package prisma
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func TestReadDatabase_Prisma7GeneratorSetsSourceFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
schemaPath := filepath.Join(tmpDir, "schema.prisma")
|
||||
|
||||
content := `datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "./generated"
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(schemaPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write schema: %v", err)
|
||||
}
|
||||
|
||||
reader := NewReader(&readers.ReaderOptions{FilePath: schemaPath})
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() failed: %v", err)
|
||||
}
|
||||
|
||||
if db.SourceFormat != "prisma7" {
|
||||
t.Fatalf("expected SourceFormat prisma7, got %q", db.SourceFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDatabase_Prisma7FlagSetsSourceFormatWithoutGenerator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
schemaPath := filepath.Join(tmpDir, "schema.prisma")
|
||||
|
||||
content := `datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(schemaPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write schema: %v", err)
|
||||
}
|
||||
|
||||
reader := NewReader(&readers.ReaderOptions{
|
||||
FilePath: schemaPath,
|
||||
Prisma7: true,
|
||||
})
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() failed: %v", err)
|
||||
}
|
||||
|
||||
if db.SourceFormat != "prisma7" {
|
||||
t.Fatalf("expected SourceFormat prisma7 from flag, got %q", db.SourceFormat)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ type ReaderOptions struct {
|
||||
// ConnectionString is the database connection string (for DB readers)
|
||||
ConnectionString string
|
||||
|
||||
// Prisma7 enables Prisma 7-specific handling for Prisma schemas.
|
||||
Prisma7 bool
|
||||
|
||||
// Additional options can be added here as needed
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
sqlitepkg "git.warky.dev/wdevs/relspecgo/pkg/sqlite"
|
||||
)
|
||||
|
||||
// Reader implements the readers.Reader interface for SQLite databases
|
||||
@@ -183,59 +184,9 @@ func (r *Reader) close() {
|
||||
}
|
||||
}
|
||||
|
||||
// mapDataType maps SQLite data types to canonical types
|
||||
// mapDataType maps SQLite data types to canonical types.
|
||||
func (r *Reader) mapDataType(sqliteType string) string {
|
||||
// SQLite has a flexible type system, but we map common types
|
||||
typeMap := map[string]string{
|
||||
"INTEGER": "int",
|
||||
"INT": "int",
|
||||
"TINYINT": "int8",
|
||||
"SMALLINT": "int16",
|
||||
"MEDIUMINT": "int",
|
||||
"BIGINT": "int64",
|
||||
"UNSIGNED BIG INT": "uint64",
|
||||
"INT2": "int16",
|
||||
"INT8": "int64",
|
||||
"REAL": "float64",
|
||||
"DOUBLE": "float64",
|
||||
"DOUBLE PRECISION": "float64",
|
||||
"FLOAT": "float32",
|
||||
"NUMERIC": "decimal",
|
||||
"DECIMAL": "decimal",
|
||||
"BOOLEAN": "bool",
|
||||
"BOOL": "bool",
|
||||
"DATE": "date",
|
||||
"DATETIME": "timestamp",
|
||||
"TIMESTAMP": "timestamp",
|
||||
"TEXT": "string",
|
||||
"VARCHAR": "string",
|
||||
"CHAR": "string",
|
||||
"CHARACTER": "string",
|
||||
"VARYING CHARACTER": "string",
|
||||
"NCHAR": "string",
|
||||
"NVARCHAR": "string",
|
||||
"CLOB": "text",
|
||||
"BLOB": "bytea",
|
||||
}
|
||||
|
||||
// Try exact match first
|
||||
if mapped, exists := typeMap[sqliteType]; exists {
|
||||
return mapped
|
||||
}
|
||||
|
||||
// Try case-insensitive match for common types
|
||||
sqliteTypeUpper := sqliteType
|
||||
if len(sqliteType) > 0 {
|
||||
// Extract base type (e.g., "VARCHAR(255)" -> "VARCHAR")
|
||||
for baseType := range typeMap {
|
||||
if len(sqliteTypeUpper) >= len(baseType) && sqliteTypeUpper[:len(baseType)] == baseType {
|
||||
return typeMap[baseType]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to string for unknown types
|
||||
return "string"
|
||||
return sqlitepkg.ConvertSQLiteToCanonical(sqliteType)
|
||||
}
|
||||
|
||||
// deriveRelationship creates a relationship from a foreign key constraint
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
// Deref dereferences pointers until it reaches a non-pointer value
|
||||
// Returns the dereferenced value and true if successful, or the original value and false if nil
|
||||
func Deref(v reflect.Value) (reflect.Value, bool) {
|
||||
for v.Kind() == reflect.Ptr {
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return v, false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package sqlite
|
||||
|
||||
import "strings"
|
||||
|
||||
// SQLiteToCanonicalTypes maps SQLite type names to canonical types.
|
||||
// SQLite has type affinity rules; this maps common type names including
|
||||
// MySQL/PostgreSQL types that users write in SQLite schemas.
|
||||
var SQLiteToCanonicalTypes = map[string]string{
|
||||
// Integer affinity
|
||||
"integer": "int",
|
||||
"int": "int",
|
||||
"tinyint": "int8",
|
||||
"smallint": "int16",
|
||||
"mediumint": "int",
|
||||
"bigint": "int64",
|
||||
"unsigned big int": "uint64",
|
||||
"int2": "int16",
|
||||
"int8": "int64",
|
||||
// Real affinity
|
||||
"real": "float64",
|
||||
"double": "float64",
|
||||
"double precision": "float64",
|
||||
"float": "float32",
|
||||
// Numeric affinity
|
||||
"numeric": "decimal",
|
||||
"decimal": "decimal",
|
||||
// Boolean (stored as integer in SQLite)
|
||||
"boolean": "bool",
|
||||
"bool": "bool",
|
||||
// Date/time (stored as text in SQLite)
|
||||
"date": "date",
|
||||
"datetime": "timestamp",
|
||||
"timestamp": "timestamp",
|
||||
// Text affinity
|
||||
"text": "string",
|
||||
"varchar": "string",
|
||||
"char": "string",
|
||||
"character": "string",
|
||||
"varying character": "string",
|
||||
"nchar": "string",
|
||||
"nvarchar": "string",
|
||||
"clob": "text",
|
||||
// Blob affinity
|
||||
"blob": "bytea",
|
||||
}
|
||||
|
||||
// CanonicalToSQLiteAffinity maps type names to SQLite type affinity names.
|
||||
// Accepts both Go canonical names ("int", "string") and SQL canonical names
|
||||
// ("integer", "varchar") so the writer handles input from any reader.
|
||||
// The five SQLite type affinities are TEXT, INTEGER, REAL, NUMERIC, BLOB.
|
||||
var CanonicalToSQLiteAffinity = map[string]string{
|
||||
// INTEGER affinity — Go canonical
|
||||
"int": "INTEGER",
|
||||
"int8": "INTEGER",
|
||||
"int16": "INTEGER",
|
||||
"int32": "INTEGER",
|
||||
"int64": "INTEGER",
|
||||
"uint": "INTEGER",
|
||||
"uint8": "INTEGER",
|
||||
"uint16": "INTEGER",
|
||||
"uint32": "INTEGER",
|
||||
"uint64": "INTEGER",
|
||||
"bool": "INTEGER",
|
||||
// INTEGER affinity — SQL canonical
|
||||
"integer": "INTEGER",
|
||||
"smallint": "INTEGER",
|
||||
"bigint": "INTEGER",
|
||||
"serial": "INTEGER",
|
||||
"smallserial": "INTEGER",
|
||||
"bigserial": "INTEGER",
|
||||
"boolean": "INTEGER",
|
||||
"tinyint": "INTEGER",
|
||||
"mediumint": "INTEGER",
|
||||
// REAL affinity — Go canonical
|
||||
"float32": "REAL",
|
||||
"float64": "REAL",
|
||||
// REAL affinity — SQL canonical
|
||||
"real": "REAL",
|
||||
"float": "REAL",
|
||||
"double": "REAL",
|
||||
"double precision": "REAL",
|
||||
// NUMERIC affinity
|
||||
"decimal": "NUMERIC",
|
||||
"numeric": "NUMERIC",
|
||||
"money": "NUMERIC",
|
||||
"smallmoney": "NUMERIC",
|
||||
// BLOB affinity
|
||||
"bytea": "BLOB",
|
||||
"blob": "BLOB",
|
||||
// TEXT affinity — Go canonical
|
||||
"string": "TEXT",
|
||||
"text": "TEXT",
|
||||
// TEXT affinity — SQL canonical
|
||||
"varchar": "TEXT",
|
||||
"char": "TEXT",
|
||||
"nvarchar": "TEXT",
|
||||
"nchar": "TEXT",
|
||||
"citext": "TEXT",
|
||||
"date": "TEXT",
|
||||
"time": "TEXT",
|
||||
"timetz": "TEXT",
|
||||
"timestamp": "TEXT",
|
||||
"timestamptz": "TEXT",
|
||||
"datetime": "TEXT",
|
||||
"uuid": "TEXT",
|
||||
"json": "TEXT",
|
||||
"jsonb": "TEXT",
|
||||
"xml": "TEXT",
|
||||
"inet": "TEXT",
|
||||
"cidr": "TEXT",
|
||||
"macaddr": "TEXT",
|
||||
}
|
||||
|
||||
// ConvertSQLiteToCanonical converts a SQLite type name to the canonical type.
|
||||
// Strips dimension parameters (e.g. VARCHAR(255) → string) and handles
|
||||
// SQLite's flexible affinity rules. Defaults to "string" for unknown types.
|
||||
func ConvertSQLiteToCanonical(sqliteType string) string {
|
||||
base := strings.ToUpper(strings.TrimSpace(sqliteType))
|
||||
if idx := strings.Index(base, "("); idx >= 0 {
|
||||
base = strings.TrimSpace(base[:idx])
|
||||
}
|
||||
lower := strings.ToLower(base)
|
||||
|
||||
if canonical, ok := SQLiteToCanonicalTypes[lower]; ok {
|
||||
return canonical
|
||||
}
|
||||
|
||||
// Prefix match for types like "VARYING CHARACTER(255)"
|
||||
for key, canonical := range SQLiteToCanonicalTypes {
|
||||
if strings.HasPrefix(lower, key) {
|
||||
return canonical
|
||||
}
|
||||
}
|
||||
|
||||
return "string"
|
||||
}
|
||||
|
||||
// ConvertCanonicalToSQLite converts a canonical type (or any SQL type) to its
|
||||
// SQLite type affinity. Defaults to TEXT for unrecognised types.
|
||||
func ConvertCanonicalToSQLite(canonicalType string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(canonicalType))
|
||||
if idx := strings.Index(normalized, "("); idx >= 0 {
|
||||
normalized = strings.TrimSpace(normalized[:idx])
|
||||
}
|
||||
normalized = strings.TrimSuffix(normalized, "[]")
|
||||
|
||||
if affinity, ok := CanonicalToSQLiteAffinity[normalized]; ok {
|
||||
return affinity
|
||||
}
|
||||
|
||||
return "TEXT"
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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}}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+101
-37
@@ -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,54 +48,67 @@ func main() {
|
||||
### CLI Examples
|
||||
|
||||
```bash
|
||||
# Generate Bun models from PostgreSQL database
|
||||
relspec --input pgsql \
|
||||
--conn "postgres://localhost/mydb" \
|
||||
--output bun \
|
||||
--out-file models.go \
|
||||
--package models
|
||||
# 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
|
||||
|
||||
# Convert GORM models to Bun
|
||||
relspec --input gorm --in-file gorm_models.go --output bun --out-file bun_models.go
|
||||
# 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
|
||||
|
||||
# Multi-file output
|
||||
relspec --input json --in-file schema.json --output bun --out-file models/
|
||||
# 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 sqltypes
|
||||
|
||||
# Multi-file output (one file per table)
|
||||
relspec convert --from json --from-path schema.json \
|
||||
--to bun --to-path models/ --package models
|
||||
```
|
||||
|
||||
## Generated Code Example
|
||||
## Generated Code Examples
|
||||
|
||||
### sqltypes package types (`--types sqltypes`)
|
||||
|
||||
```go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
"database/sql"
|
||||
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,pk,autoincrement" json:"id"`
|
||||
Username string `bun:"username,notnull,unique" json:"username"`
|
||||
Email string `bun:"email,notnull" json:"email"`
|
||||
Bio sql.NullString `bun:"bio" json:"bio,omitempty"`
|
||||
CreatedAt time.Time `bun:"created_at,notnull,default:now()" json:"created_at"`
|
||||
|
||||
// Relationships
|
||||
Posts []*Post `bun:"rel:has-many,join:id=user_id" json:"posts,omitempty"`
|
||||
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"`
|
||||
}
|
||||
```
|
||||
|
||||
type Post struct {
|
||||
bun.BaseModel `bun:"table:posts,alias:p"`
|
||||
### Standard library — `--types stdlib`
|
||||
|
||||
ID int64 `bun:"id,pk" json:"id"`
|
||||
UserID int64 `bun:"user_id,notnull" json:"user_id"`
|
||||
Title string `bun:"title,notnull" json:"title"`
|
||||
Content sql.NullString `bun:"content" json:"content,omitempty"`
|
||||
```go
|
||||
package models
|
||||
|
||||
// Belongs to
|
||||
User *User `bun:"rel:belongs-to,join:user_id=id" json:"user,omitempty"`
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
bun.BaseModel `bun:"table:users,alias:u"`
|
||||
|
||||
ID string `bun:"id,type:uuid,pk," json:"id"`
|
||||
Username string `bun:"username,type:text,notnull," json:"username"`
|
||||
Email sql.NullString `bun:"email,type:text,nullzero," json:"email"`
|
||||
Tags []string `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||
CreatedAt time.Time `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
}
|
||||
```
|
||||
|
||||
@@ -111,19 +126,68 @@ type Post struct {
|
||||
|
||||
## Type Mapping
|
||||
|
||||
| SQL Type | Go Type | Nullable Type |
|
||||
|----------|---------|---------------|
|
||||
| bigint | int64 | sql.NullInt64 |
|
||||
| integer | int | sql.NullInt32 |
|
||||
| varchar, text | string | sql.NullString |
|
||||
| boolean | bool | sql.NullBool |
|
||||
| timestamp | time.Time | sql.NullTime |
|
||||
| numeric | float64 | sql.NullFloat64 |
|
||||
The nullable type package is selected with `--types` (or `WriterOptions.NullableTypes`).
|
||||
|
||||
| SQL Type | NOT NULL (both) | Nullable — sqltypes | Nullable — stdlib |
|
||||
|---|---|---|---|
|
||||
| `bigint` | `int64` | `SqlInt64` | `sql.NullInt64` |
|
||||
| `integer` | `int32` | `SqlInt32` | `sql.NullInt32` |
|
||||
| `smallint` | `int16` | `SqlInt16` | `sql.NullInt16` |
|
||||
| `text`, `varchar` | `string` | `SqlString` | `sql.NullString` |
|
||||
| `boolean` | `bool` | `SqlBool` | `sql.NullBool` |
|
||||
| `timestamp`, `timestamptz` | `time.Time`* | `SqlTimeStamp` | `sql.NullTime` |
|
||||
| `numeric`, `decimal` | `float64` | `SqlFloat64` | `sql.NullFloat64` |
|
||||
| `uuid` | `string` | `SqlUUID` | `sql.NullString` |
|
||||
| `jsonb` | `string` | `SqlJSONB` | `sql.NullString` |
|
||||
| `text[]` | `SqlStringArray` | `SqlStringArray` | `[]string` |
|
||||
| `integer[]` | `SqlInt32Array` | `SqlInt32Array` | `[]int32` |
|
||||
| `uuid[]` | `SqlUUIDArray` | `SqlUUIDArray` | `[]string` |
|
||||
| `vector` | `SqlVector` | `SqlVector` | `[]float32` |
|
||||
|
||||
\* 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
|
||||
|
||||
### NullableTypes
|
||||
|
||||
Controls which Go package is used for nullable column types. Set via the `--types` CLI flag or `WriterOptions.NullableTypes`:
|
||||
|
||||
```go
|
||||
// Use sqltypes package types
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "models.go",
|
||||
PackageName: "models",
|
||||
NullableTypes: writers.NullableTypeSqlTypes,
|
||||
}
|
||||
|
||||
// Use standard library database/sql types
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "models.go",
|
||||
PackageName: "models",
|
||||
NullableTypes: writers.NullableTypeStdlib,
|
||||
}
|
||||
```
|
||||
|
||||
### Metadata Options
|
||||
|
||||
```go
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "models.go",
|
||||
PackageName: "models",
|
||||
Metadata: map[string]any{
|
||||
"multi_file": true, // Enable multi-file mode
|
||||
"populate_refs": true, // Populate RefDatabase/RefSchema
|
||||
"generate_get_id_str": true, // Generate GetIDStr() methods
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Model names are derived from table names (singularized, PascalCase)
|
||||
- Table aliases are auto-generated from table names
|
||||
- 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
|
||||
|
||||
@@ -18,17 +18,20 @@ type TemplateData struct {
|
||||
|
||||
// ModelData represents a single model/struct in the template
|
||||
type ModelData struct {
|
||||
Name string
|
||||
TableName string // schema.table format
|
||||
SchemaName string
|
||||
TableNameOnly string // just table name without schema
|
||||
Comment string
|
||||
Fields []*FieldData
|
||||
Config *MethodConfig
|
||||
PrimaryKeyField string // Name of the primary key field
|
||||
PrimaryKeyIsSQL bool // Whether PK uses SQL type (needs .Int64() call)
|
||||
IDColumnName string // Name of the ID column in database
|
||||
Prefix string // 3-letter prefix
|
||||
Name string
|
||||
TableName string // schema.table format
|
||||
SchemaName string
|
||||
TableNameOnly string // just table name without schema
|
||||
Comment string
|
||||
Fields []*FieldData
|
||||
Config *MethodConfig
|
||||
PrimaryKeyField string // Name of the primary key field
|
||||
PrimaryKeyType string // Go type of the primary key field
|
||||
PrimaryKeyIsSQL bool // Whether PK uses SQL type (needs .Int64() call)
|
||||
PrimaryKeyIsStr bool // Whether helper methods should use string IDs
|
||||
PrimaryKeyIDType string // Helper method GetID/SetID/UpdateID type
|
||||
IDColumnName string // Name of the ID column in database
|
||||
Prefix string // 3-letter prefix
|
||||
}
|
||||
|
||||
// FieldData represents a single field in a struct
|
||||
@@ -138,9 +141,15 @@ 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.PrimaryKeyIsSQL = strings.Contains(goType, "resolvespec_common") || strings.Contains(goType, "sql_types")
|
||||
model.PrimaryKeyType = goType
|
||||
model.PrimaryKeyIsSQL = strings.Contains(goType, "sql_types")
|
||||
model.PrimaryKeyIsStr = isStringLikePrimaryKeyType(goType)
|
||||
model.PrimaryKeyIDType = "int64"
|
||||
if model.PrimaryKeyIsStr {
|
||||
model.PrimaryKeyIDType = "string"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -192,6 +201,15 @@ func formatComment(description, comment string) string {
|
||||
return comment
|
||||
}
|
||||
|
||||
func isStringLikePrimaryKeyType(goType string) bool {
|
||||
switch goType {
|
||||
case "string", "sql.NullString", "sql_types.SqlString", "sql_types.SqlUUID":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resolveFieldNameCollision checks if a field name conflicts with generated method names
|
||||
// and adds an underscore suffix if there's a collision
|
||||
func resolveFieldNameCollision(fieldName string) string {
|
||||
|
||||
@@ -44,33 +44,55 @@ func (m {{.Name}}) SchemaName() string {
|
||||
{{end}}
|
||||
{{if and .Config.GenerateGetID .PrimaryKeyField}}
|
||||
// GetID returns the primary key value
|
||||
func (m {{.Name}}) GetID() int64 {
|
||||
func (m {{.Name}}) GetID() {{.PrimaryKeyIDType}} {
|
||||
{{if .PrimaryKeyIsSQL -}}
|
||||
{{if .PrimaryKeyIsStr -}}
|
||||
return m.{{.PrimaryKeyField}}.String()
|
||||
{{- else -}}
|
||||
return m.{{.PrimaryKeyField}}.Int64()
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
{{if .PrimaryKeyIsStr -}}
|
||||
return m.{{.PrimaryKeyField}}
|
||||
{{- else -}}
|
||||
return int64(m.{{.PrimaryKeyField}})
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{end}}
|
||||
{{if and .Config.GenerateGetIDStr .PrimaryKeyField}}
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m {{.Name}}) GetIDStr() string {
|
||||
{{if .PrimaryKeyIsSQL -}}
|
||||
return m.{{.PrimaryKeyField}}.String()
|
||||
{{- else if .PrimaryKeyIsStr -}}
|
||||
return m.{{.PrimaryKeyField}}
|
||||
{{- else -}}
|
||||
return fmt.Sprintf("%d", m.{{.PrimaryKeyField}})
|
||||
{{- end}}
|
||||
}
|
||||
{{end}}
|
||||
{{if and .Config.GenerateSetID .PrimaryKeyField}}
|
||||
// SetID sets the primary key value
|
||||
func (m {{.Name}}) SetID(newid int64) {
|
||||
func (m {{.Name}}) SetID(newid {{.PrimaryKeyIDType}}) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
{{end}}
|
||||
{{if and .Config.GenerateUpdateID .PrimaryKeyField}}
|
||||
// UpdateID updates the primary key value
|
||||
func (m *{{.Name}}) UpdateID(newid int64) {
|
||||
func (m *{{.Name}}) UpdateID(newid {{.PrimaryKeyIDType}}) {
|
||||
{{if .PrimaryKeyIsSQL -}}
|
||||
m.{{.PrimaryKeyField}}.FromString(fmt.Sprintf("%d", newid))
|
||||
{{if .PrimaryKeyIsStr -}}
|
||||
m.{{.PrimaryKeyField}}.FromString(newid)
|
||||
{{- else -}}
|
||||
m.{{.PrimaryKeyField}} = int32(newid)
|
||||
m.{{.PrimaryKeyField}}.FromString(fmt.Sprintf("%d", newid))
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
{{if .PrimaryKeyIsStr -}}
|
||||
m.{{.PrimaryKeyField}} = newid
|
||||
{{- else -}}
|
||||
m.{{.PrimaryKeyField}} = {{.PrimaryKeyType}}(newid)
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{end}}
|
||||
|
||||
+204
-17
@@ -11,30 +11,49 @@ import (
|
||||
|
||||
// TypeMapper handles type conversions between SQL and Go types for Bun
|
||||
type TypeMapper struct {
|
||||
// Package alias for sql_types import
|
||||
sqlTypesAlias string
|
||||
typeStyle string // writers.NullableTypeSqlTypes | writers.NullableTypeStdlib | writers.NullableTypeBaselib
|
||||
}
|
||||
|
||||
// NewTypeMapper creates a new TypeMapper with default settings
|
||||
func NewTypeMapper() *TypeMapper {
|
||||
// NewTypeMapper creates a new TypeMapper.
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// SQLTypeToGoType converts a SQL type to its Go equivalent
|
||||
// Uses ResolveSpec common package types (all are nullable by default in Bun)
|
||||
// SQLTypeToGoType converts a SQL type to its Go equivalent.
|
||||
func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string {
|
||||
// Normalize SQL type (lowercase, remove length/precision)
|
||||
// Array types are handled separately for both styles.
|
||||
if pgsql.IsArrayType(sqlType) {
|
||||
return tm.arrayGoType(tm.extractBaseType(sqlType))
|
||||
}
|
||||
|
||||
baseType := tm.extractBaseType(sqlType)
|
||||
|
||||
// For Bun, we typically use resolvespec_common types for most fields
|
||||
// unless they're explicitly NOT NULL and we want to avoid null handling
|
||||
switch tm.typeStyle {
|
||||
case writers.NullableTypeStdlib:
|
||||
if notNull {
|
||||
return tm.rawGoType(baseType)
|
||||
}
|
||||
return tm.stdlibNullableGoType(baseType)
|
||||
case writers.NullableTypeBaselib:
|
||||
if notNull {
|
||||
return tm.rawGoType(baseType)
|
||||
}
|
||||
return tm.baselibNullableGoType(baseType)
|
||||
}
|
||||
|
||||
// sqltypes: use base Go types only for simple NOT NULL fields.
|
||||
if notNull && tm.isSimpleType(baseType) {
|
||||
return tm.baseGoType(baseType)
|
||||
}
|
||||
|
||||
// Use resolvespec_common types for nullable fields
|
||||
return tm.bunGoType(baseType)
|
||||
}
|
||||
|
||||
@@ -87,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
|
||||
@@ -154,6 +173,9 @@ func (tm *TypeMapper) bunGoType(sqlType string) string {
|
||||
|
||||
// Other
|
||||
"money": tm.sqlTypesAlias + ".SqlFloat64",
|
||||
|
||||
// pgvector
|
||||
"vector": tm.sqlTypesAlias + ".SqlVector",
|
||||
}
|
||||
|
||||
if goType, ok := typeMap[sqlType]; ok {
|
||||
@@ -164,6 +186,155 @@ func (tm *TypeMapper) bunGoType(sqlType string) string {
|
||||
return tm.sqlTypesAlias + ".SqlString"
|
||||
}
|
||||
|
||||
// arrayGoType returns the Go type for a PostgreSQL array column.
|
||||
// The baseElemType is the canonical base type (e.g. "text", "integer").
|
||||
func (tm *TypeMapper) arrayGoType(baseElemType string) string {
|
||||
if tm.typeStyle == writers.NullableTypeStdlib || tm.typeStyle == writers.NullableTypeBaselib {
|
||||
return tm.stdlibArrayGoType(baseElemType)
|
||||
}
|
||||
typeMap := map[string]string{
|
||||
"text": tm.sqlTypesAlias + ".SqlStringArray", "varchar": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"char": tm.sqlTypesAlias + ".SqlStringArray", "character": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"citext": tm.sqlTypesAlias + ".SqlStringArray", "bpchar": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"inet": tm.sqlTypesAlias + ".SqlStringArray", "cidr": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"macaddr": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"json": tm.sqlTypesAlias + ".SqlStringArray", "jsonb": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"integer": tm.sqlTypesAlias + ".SqlInt32Array", "int": tm.sqlTypesAlias + ".SqlInt32Array",
|
||||
"int4": tm.sqlTypesAlias + ".SqlInt32Array", "serial": tm.sqlTypesAlias + ".SqlInt32Array",
|
||||
"smallint": tm.sqlTypesAlias + ".SqlInt16Array", "int2": tm.sqlTypesAlias + ".SqlInt16Array",
|
||||
"smallserial": tm.sqlTypesAlias + ".SqlInt16Array",
|
||||
"bigint": tm.sqlTypesAlias + ".SqlInt64Array", "int8": tm.sqlTypesAlias + ".SqlInt64Array",
|
||||
"bigserial": tm.sqlTypesAlias + ".SqlInt64Array",
|
||||
"real": tm.sqlTypesAlias + ".SqlFloat32Array", "float4": tm.sqlTypesAlias + ".SqlFloat32Array",
|
||||
"double precision": tm.sqlTypesAlias + ".SqlFloat64Array", "float8": tm.sqlTypesAlias + ".SqlFloat64Array",
|
||||
"numeric": tm.sqlTypesAlias + ".SqlFloat64Array", "decimal": tm.sqlTypesAlias + ".SqlFloat64Array",
|
||||
"money": tm.sqlTypesAlias + ".SqlFloat64Array",
|
||||
"boolean": tm.sqlTypesAlias + ".SqlBoolArray", "bool": tm.sqlTypesAlias + ".SqlBoolArray",
|
||||
"uuid": tm.sqlTypesAlias + ".SqlUUIDArray",
|
||||
}
|
||||
if goType, ok := typeMap[baseElemType]; ok {
|
||||
return goType
|
||||
}
|
||||
return tm.sqlTypesAlias + ".SqlStringArray"
|
||||
}
|
||||
|
||||
// rawGoType returns the plain Go type for a NOT NULL column in stdlib mode.
|
||||
func (tm *TypeMapper) rawGoType(sqlType string) string {
|
||||
typeMap := map[string]string{
|
||||
"integer": "int32", "int": "int32", "int4": "int32", "serial": "int32",
|
||||
"smallint": "int16", "int2": "int16", "smallserial": "int16",
|
||||
"bigint": "int64", "int8": "int64", "bigserial": "int64",
|
||||
"boolean": "bool", "bool": "bool",
|
||||
"real": "float32", "float4": "float32",
|
||||
"double precision": "float64", "float8": "float64",
|
||||
"numeric": "float64", "decimal": "float64", "money": "float64",
|
||||
"text": "string", "varchar": "string", "char": "string",
|
||||
"character": "string", "citext": "string", "bpchar": "string",
|
||||
"inet": "string", "cidr": "string", "macaddr": "string",
|
||||
"uuid": "string", "json": "string", "jsonb": "string",
|
||||
"timestamp": "time.Time",
|
||||
"timestamp without time zone": "time.Time",
|
||||
"timestamp with time zone": "time.Time",
|
||||
"timestamptz": "time.Time",
|
||||
"date": "time.Time",
|
||||
"time": "time.Time",
|
||||
"time without time zone": "time.Time",
|
||||
"time with time zone": "time.Time",
|
||||
"timetz": "time.Time",
|
||||
"bytea": "[]byte",
|
||||
"vector": "[]float32",
|
||||
}
|
||||
if goType, ok := typeMap[sqlType]; ok {
|
||||
return goType
|
||||
}
|
||||
return "string"
|
||||
}
|
||||
|
||||
// stdlibNullableGoType returns the database/sql nullable type for a column.
|
||||
func (tm *TypeMapper) stdlibNullableGoType(sqlType string) string {
|
||||
typeMap := map[string]string{
|
||||
"integer": "sql.NullInt32", "int": "sql.NullInt32", "int4": "sql.NullInt32", "serial": "sql.NullInt32",
|
||||
"smallint": "sql.NullInt16", "int2": "sql.NullInt16", "smallserial": "sql.NullInt16",
|
||||
"bigint": "sql.NullInt64", "int8": "sql.NullInt64", "bigserial": "sql.NullInt64",
|
||||
"boolean": "sql.NullBool", "bool": "sql.NullBool",
|
||||
"real": "sql.NullFloat64", "float4": "sql.NullFloat64",
|
||||
"double precision": "sql.NullFloat64", "float8": "sql.NullFloat64",
|
||||
"numeric": "sql.NullFloat64", "decimal": "sql.NullFloat64", "money": "sql.NullFloat64",
|
||||
"text": "sql.NullString", "varchar": "sql.NullString", "char": "sql.NullString",
|
||||
"character": "sql.NullString", "citext": "sql.NullString", "bpchar": "sql.NullString",
|
||||
"inet": "sql.NullString", "cidr": "sql.NullString", "macaddr": "sql.NullString",
|
||||
"uuid": "sql.NullString", "json": "sql.NullString", "jsonb": "sql.NullString",
|
||||
"timestamp": "sql.NullTime",
|
||||
"timestamp without time zone": "sql.NullTime",
|
||||
"timestamp with time zone": "sql.NullTime",
|
||||
"timestamptz": "sql.NullTime",
|
||||
"date": "sql.NullTime",
|
||||
"time": "sql.NullTime",
|
||||
"time without time zone": "sql.NullTime",
|
||||
"time with time zone": "sql.NullTime",
|
||||
"timetz": "sql.NullTime",
|
||||
"bytea": "[]byte",
|
||||
"vector": "[]float32",
|
||||
}
|
||||
if goType, ok := typeMap[sqlType]; ok {
|
||||
return goType
|
||||
}
|
||||
return "sql.NullString"
|
||||
}
|
||||
|
||||
// baselibNullableGoType returns plain Go pointer types for nullable columns.
|
||||
func (tm *TypeMapper) baselibNullableGoType(sqlType string) string {
|
||||
typeMap := map[string]string{
|
||||
"integer": "*int32", "int": "*int32", "int4": "*int32", "serial": "*int32",
|
||||
"smallint": "*int16", "int2": "*int16", "smallserial": "*int16",
|
||||
"bigint": "*int64", "int8": "*int64", "bigserial": "*int64",
|
||||
"boolean": "*bool", "bool": "*bool",
|
||||
"real": "*float32", "float4": "*float32",
|
||||
"double precision": "*float64", "float8": "*float64",
|
||||
"numeric": "*float64", "decimal": "*float64", "money": "*float64",
|
||||
"text": "*string", "varchar": "*string", "char": "*string",
|
||||
"character": "*string", "citext": "*string", "bpchar": "*string",
|
||||
"inet": "*string", "cidr": "*string", "macaddr": "*string",
|
||||
"uuid": "*string", "json": "*string", "jsonb": "*string",
|
||||
"timestamp": "*time.Time",
|
||||
"timestamp without time zone": "*time.Time",
|
||||
"timestamp with time zone": "*time.Time",
|
||||
"timestamptz": "*time.Time",
|
||||
"date": "*time.Time",
|
||||
"time": "*time.Time",
|
||||
"time without time zone": "*time.Time",
|
||||
"time with time zone": "*time.Time",
|
||||
"timetz": "*time.Time",
|
||||
"bytea": "[]byte",
|
||||
"vector": "[]float32",
|
||||
}
|
||||
if goType, ok := typeMap[sqlType]; ok {
|
||||
return goType
|
||||
}
|
||||
return "*string"
|
||||
}
|
||||
|
||||
// stdlibArrayGoType returns a plain Go slice type for array columns in stdlib mode.
|
||||
func (tm *TypeMapper) stdlibArrayGoType(baseElemType string) string {
|
||||
typeMap := map[string]string{
|
||||
"text": "[]string", "varchar": "[]string", "char": "[]string",
|
||||
"character": "[]string", "citext": "[]string", "bpchar": "[]string",
|
||||
"inet": "[]string", "cidr": "[]string", "macaddr": "[]string",
|
||||
"uuid": "[]string", "json": "[]string", "jsonb": "[]string",
|
||||
"integer": "[]int32", "int": "[]int32", "int4": "[]int32", "serial": "[]int32",
|
||||
"smallint": "[]int16", "int2": "[]int16", "smallserial": "[]int16",
|
||||
"bigint": "[]int64", "int8": "[]int64", "bigserial": "[]int64",
|
||||
"real": "[]float32", "float4": "[]float32",
|
||||
"double precision": "[]float64", "float8": "[]float64",
|
||||
"numeric": "[]float64", "decimal": "[]float64", "money": "[]float64",
|
||||
"boolean": "[]bool", "bool": "[]bool",
|
||||
}
|
||||
if goType, ok := typeMap[baseElemType]; ok {
|
||||
return goType
|
||||
}
|
||||
return "[]string"
|
||||
}
|
||||
|
||||
// BuildBunTag generates a complete Bun tag string for a column
|
||||
// Bun format: bun:"column_name,type:type_name,pk,default:value"
|
||||
func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) string {
|
||||
@@ -178,10 +349,11 @@ func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) st
|
||||
if column.Type != "" {
|
||||
// Sanitize type to remove backticks
|
||||
typeStr := writers.SanitizeStructTagValue(column.Type)
|
||||
isArray := pgsql.IsArrayType(typeStr)
|
||||
hasExplicitTypeModifier := pgsql.HasExplicitTypeModifier(typeStr)
|
||||
if !hasExplicitTypeModifier && column.Length > 0 {
|
||||
if !hasExplicitTypeModifier && !isArray && column.Length > 0 {
|
||||
typeStr = fmt.Sprintf("%s(%d)", typeStr, column.Length)
|
||||
} else if !hasExplicitTypeModifier && column.Precision > 0 {
|
||||
} else if !hasExplicitTypeModifier && !isArray && column.Precision > 0 {
|
||||
if column.Scale > 0 {
|
||||
typeStr = fmt.Sprintf("%s(%d,%d)", typeStr, column.Precision, column.Scale)
|
||||
} else {
|
||||
@@ -189,6 +361,9 @@ func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) st
|
||||
}
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("type:%s", typeStr))
|
||||
if isArray && tm.typeStyle == writers.NullableTypeStdlib {
|
||||
parts = append(parts, "array")
|
||||
}
|
||||
}
|
||||
|
||||
// Primary key
|
||||
@@ -286,9 +461,21 @@ func (tm *TypeMapper) NeedsFmtImport(generateGetIDStr bool) bool {
|
||||
return generateGetIDStr
|
||||
}
|
||||
|
||||
// GetSQLTypesImport returns the import path for sql_types (ResolveSpec common)
|
||||
// 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
|
||||
// package (ready to pass to AddImport). Returns empty string when no import is needed.
|
||||
func (tm *TypeMapper) GetNullableTypeImportLine() string {
|
||||
switch tm.typeStyle {
|
||||
case writers.NullableTypeStdlib:
|
||||
return "\"database/sql\""
|
||||
case writers.NullableTypeBaselib:
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s \"%s\"", tm.sqlTypesAlias, tm.GetSQLTypesImport())
|
||||
}
|
||||
|
||||
// GetBunImport returns the import path for Bun
|
||||
|
||||
@@ -24,7 +24,7 @@ type Writer struct {
|
||||
func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
w := &Writer{
|
||||
options: options,
|
||||
typeMapper: NewTypeMapper(),
|
||||
typeMapper: NewTypeMapper(options.NullableTypes),
|
||||
config: LoadMethodConfigFromMetadata(options.Metadata),
|
||||
}
|
||||
|
||||
@@ -80,8 +80,8 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
|
||||
// Add bun import (always needed)
|
||||
templateData.AddImport(fmt.Sprintf("\"%s\"", w.typeMapper.GetBunImport()))
|
||||
|
||||
// Add resolvespec_common import (always needed for nullable types)
|
||||
templateData.AddImport(fmt.Sprintf("resolvespec_common \"%s\"", w.typeMapper.GetSQLTypesImport()))
|
||||
// Add nullable types import (sqltypes or stdlib depending on options)
|
||||
templateData.AddImport(w.typeMapper.GetNullableTypeImportLine())
|
||||
|
||||
// Collect all models
|
||||
for _, schema := range db.Schemas {
|
||||
@@ -102,8 +102,8 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Add fmt import if GetIDStr is enabled
|
||||
if w.config.GenerateGetIDStr {
|
||||
// Add fmt import when generated helper methods need string formatting.
|
||||
if w.needsFmtImport(templateData.Models) {
|
||||
templateData.AddImport("\"fmt\"")
|
||||
}
|
||||
|
||||
@@ -177,8 +177,8 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
|
||||
// Add bun import
|
||||
templateData.AddImport(fmt.Sprintf("\"%s\"", w.typeMapper.GetBunImport()))
|
||||
|
||||
// Add resolvespec_common import
|
||||
templateData.AddImport(fmt.Sprintf("resolvespec_common \"%s\"", w.typeMapper.GetSQLTypesImport()))
|
||||
// Add nullable types import (sqltypes or stdlib depending on options)
|
||||
templateData.AddImport(w.typeMapper.GetNullableTypeImportLine())
|
||||
|
||||
// Create model data
|
||||
modelData := NewModelData(table, schema.Name, w.typeMapper, w.options.FlattenSchema)
|
||||
@@ -195,8 +195,8 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Add fmt import if GetIDStr is enabled
|
||||
if w.config.GenerateGetIDStr {
|
||||
// Add fmt import when generated helper methods need string formatting.
|
||||
if w.needsFmtImport(templateData.Models) {
|
||||
templateData.AddImport("\"fmt\"")
|
||||
}
|
||||
|
||||
@@ -301,6 +301,26 @@ func (w *Writer) addRelationshipFields(modelData *ModelData, table *models.Table
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) needsFmtImport(models []*ModelData) bool {
|
||||
if w.config.GenerateGetIDStr {
|
||||
for _, model := range models {
|
||||
if model.PrimaryKeyField != "" && !model.PrimaryKeyIsSQL && !model.PrimaryKeyIsStr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if w.config.GenerateUpdateID {
|
||||
for _, model := range models {
|
||||
if model.PrimaryKeyField != "" && model.PrimaryKeyIsSQL && !model.PrimaryKeyIsStr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// findTable finds a table by schema and name in the database
|
||||
func (w *Writer) findTable(schemaName, tableName string, db *models.Database) *models.Table {
|
||||
for _, schema := range db.Schemas {
|
||||
|
||||
+171
-15
@@ -73,9 +73,9 @@ func TestWriter_WriteTable(t *testing.T) {
|
||||
"ID",
|
||||
"int64",
|
||||
"Email",
|
||||
"resolvespec_common.SqlString",
|
||||
"*string",
|
||||
"CreatedAt",
|
||||
"resolvespec_common.SqlTime",
|
||||
"time.Time",
|
||||
"bun:\"id",
|
||||
"bun:\"email",
|
||||
"func (m ModelPublicUsers) TableName() string",
|
||||
@@ -550,13 +550,13 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeMapper_SQLTypeToGoType_Bun(t *testing.T) {
|
||||
mapper := NewTypeMapper()
|
||||
mapper := NewTypeMapper("")
|
||||
|
||||
tests := []struct {
|
||||
sqlType string
|
||||
@@ -564,16 +564,20 @@ func TestTypeMapper_SQLTypeToGoType_Bun(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{"bigint", true, "int64"},
|
||||
{"bigint", false, "resolvespec_common.SqlInt64"},
|
||||
{"varchar", true, "resolvespec_common.SqlString"}, // Bun uses sql types even for NOT NULL strings
|
||||
{"varchar", false, "resolvespec_common.SqlString"},
|
||||
{"timestamp", true, "resolvespec_common.SqlTimeStamp"},
|
||||
{"timestamp", false, "resolvespec_common.SqlTimeStamp"},
|
||||
{"date", false, "resolvespec_common.SqlDate"},
|
||||
{"bigint", false, "*int64"},
|
||||
{"varchar", true, "string"},
|
||||
{"varchar", false, "*string"},
|
||||
{"timestamp", true, "time.Time"},
|
||||
{"timestamp", false, "*time.Time"},
|
||||
{"date", false, "*time.Time"},
|
||||
{"boolean", true, "bool"},
|
||||
{"boolean", false, "resolvespec_common.SqlBool"},
|
||||
{"uuid", false, "resolvespec_common.SqlUUID"},
|
||||
{"jsonb", false, "resolvespec_common.SqlJSONB"},
|
||||
{"boolean", false, "*bool"},
|
||||
{"uuid", false, "*string"},
|
||||
{"jsonb", false, "*string"},
|
||||
{"text[]", true, "[]string"},
|
||||
{"text[]", false, "[]string"},
|
||||
{"integer[]", true, "[]int32"},
|
||||
{"bigint[]", false, "[]int64"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -586,8 +590,118 @@ func TestTypeMapper_SQLTypeToGoType_Bun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_UpdateIDTypeSafety_Bun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pkType string
|
||||
expectedPK string
|
||||
expectedLine string
|
||||
forbidInt32 bool
|
||||
}{
|
||||
{"int32_pk", "int", "int32", "m.ID = int32(newid)", false},
|
||||
{"sql_int16_pk", "smallint", "int16", "m.ID = int16(newid)", true},
|
||||
{"int64_pk", "bigint", "int64", "m.ID = int64(newid)", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
table := models.InitTable("test_table", "public")
|
||||
table.Columns["id"] = &models.Column{
|
||||
Name: "id",
|
||||
Type: tt.pkType,
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
opts := &writers.WriterOptions{
|
||||
PackageName: "models",
|
||||
OutputPath: filepath.Join(tmpDir, "test.go"),
|
||||
}
|
||||
|
||||
writer := NewWriter(opts)
|
||||
err := writer.WriteTable(table)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteTable failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(opts.OutputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read generated file: %v", err)
|
||||
}
|
||||
|
||||
generated := string(content)
|
||||
|
||||
if !strings.Contains(generated, tt.expectedLine) {
|
||||
t.Errorf("Expected UpdateID to include %s\nGenerated:\n%s", tt.expectedLine, generated)
|
||||
}
|
||||
|
||||
if !strings.Contains(generated, "ID "+tt.expectedPK) {
|
||||
t.Errorf("Expected generated primary key field type %s\nGenerated:\n%s", tt.expectedPK, generated)
|
||||
}
|
||||
|
||||
if tt.forbidInt32 && strings.Contains(generated, "int32(newid)") {
|
||||
t.Errorf("UpdateID should not cast to int32 for %s type\nGenerated:\n%s", tt.pkType, generated)
|
||||
}
|
||||
|
||||
if !strings.Contains(generated, "UpdateID(newid int64)") {
|
||||
t.Errorf("UpdateID should accept int64 parameter\nGenerated:\n%s", generated)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_StringPrimaryKeyHelpers_Bun(t *testing.T) {
|
||||
table := models.InitTable("accounts", "public")
|
||||
table.Columns["id"] = &models.Column{
|
||||
Name: "id",
|
||||
Type: "uuid",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
opts := &writers.WriterOptions{
|
||||
PackageName: "models",
|
||||
OutputPath: filepath.Join(tmpDir, "test.go"),
|
||||
}
|
||||
|
||||
writer := NewWriter(opts)
|
||||
err := writer.WriteTable(table)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteTable failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(opts.OutputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read generated file: %v", err)
|
||||
}
|
||||
|
||||
generated := string(content)
|
||||
|
||||
expectations := []string{
|
||||
"ID string",
|
||||
"func (m ModelPublicAccounts) GetID() string",
|
||||
"return m.ID",
|
||||
"func (m ModelPublicAccounts) GetIDStr() string",
|
||||
"func (m ModelPublicAccounts) SetID(newid string)",
|
||||
"func (m *ModelPublicAccounts) UpdateID(newid string)",
|
||||
"m.ID = newid",
|
||||
}
|
||||
|
||||
for _, expected := range expectations {
|
||||
if !strings.Contains(generated, expected) {
|
||||
t.Errorf("Generated code missing expected content: %q\nGenerated:\n%s", expected, generated)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(generated, "GetID() int64") || strings.Contains(generated, "UpdateID(newid int64)") {
|
||||
t.Errorf("String primary keys should not use int64 helper signatures\nGenerated:\n%s", generated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeMapper_BuildBunTag(t *testing.T) {
|
||||
mapper := NewTypeMapper()
|
||||
mapper := NewTypeMapper("")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -685,6 +799,24 @@ func TestTypeMapper_BuildBunTag(t *testing.T) {
|
||||
},
|
||||
want: []string{"id,", "type:bigserial,", "pk,", "autoincrement,"},
|
||||
},
|
||||
{
|
||||
name: "text array type",
|
||||
column: &models.Column{
|
||||
Name: "tags",
|
||||
Type: "text[]",
|
||||
NotNull: false,
|
||||
},
|
||||
want: []string{"tags,", "type:text[],"},
|
||||
},
|
||||
{
|
||||
name: "integer array type",
|
||||
column: &models.Column{
|
||||
Name: "scores",
|
||||
Type: "integer[]",
|
||||
NotNull: true,
|
||||
},
|
||||
want: []string{"scores,", "type:integer[],"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -695,12 +827,36 @@ func TestTypeMapper_BuildBunTag(t *testing.T) {
|
||||
t.Errorf("BuildBunTag() = %q, missing %q", result, part)
|
||||
}
|
||||
}
|
||||
// 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 sqltypes mode", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeMapper_BuildBunTag_StdlibArrayHasArrayTag(t *testing.T) {
|
||||
mapper := NewTypeMapper(writers.NullableTypeStdlib)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
column *models.Column
|
||||
}{
|
||||
{name: "text array", column: &models.Column{Name: "tags", Type: "text[]"}},
|
||||
{name: "integer array", column: &models.Column{Name: "scores", Type: "integer[]", NotNull: true}},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := mapper.BuildBunTag(tt.column, nil)
|
||||
if !strings.Contains(result, "array") {
|
||||
t.Errorf("BuildBunTag() = %q, expected 'array' in stdlib mode", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeMapper_BuildBunTag_PreservesExplicitTypeModifiers(t *testing.T) {
|
||||
mapper := NewTypeMapper()
|
||||
mapper := NewTypeMapper("")
|
||||
|
||||
col := &models.Column{
|
||||
Name: "embedding",
|
||||
|
||||
+87
-50
@@ -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,22 +50,23 @@ func main() {
|
||||
### CLI Examples
|
||||
|
||||
```bash
|
||||
# Generate GORM models from PostgreSQL database (single file)
|
||||
relspec --input pgsql \
|
||||
--conn "postgres://localhost/mydb" \
|
||||
--output gorm \
|
||||
--out-file models.go \
|
||||
--package models
|
||||
# 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
|
||||
|
||||
# Generate GORM models with multi-file output (one file per table)
|
||||
relspec --input json \
|
||||
--in-file schema.json \
|
||||
--output gorm \
|
||||
--out-file models/ \
|
||||
--package models
|
||||
# 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
|
||||
|
||||
# Convert DBML to GORM models
|
||||
relspec --input dbml --in-file schema.dbml --output gorm --out-file models.go
|
||||
# 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 sqltypes
|
||||
|
||||
# Multi-file output (one file per table)
|
||||
relspec convert --from json --from-path schema.json \
|
||||
--to gorm --to-path models/ --package models
|
||||
```
|
||||
|
||||
## Output Modes
|
||||
@@ -86,58 +89,86 @@ relspec --input pgsql --conn "..." --output gorm --out-file models/
|
||||
|
||||
Files are named: `sql_{schema}_{table}.go`
|
||||
|
||||
## Generated Code Example
|
||||
## Generated Code Examples
|
||||
|
||||
### sqltypes package types (`--types sqltypes`)
|
||||
|
||||
```go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
sql_types "git.warky.dev/wdevs/sql_types"
|
||||
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||
)
|
||||
|
||||
type ModelUser struct {
|
||||
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"column:username;type:varchar(50);not null;uniqueIndex" json:"username"`
|
||||
Email string `gorm:"column:email;type:varchar(100);not null" json:"email"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:now()" json:"created_at"`
|
||||
|
||||
// Relationships
|
||||
Pos []*ModelPost `gorm:"foreignKey:UserID;references:ID;constraint:OnDelete:CASCADE" json:"pos,omitempty"`
|
||||
ID string `gorm:"column:id;type:uuid;primaryKey" json:"id"`
|
||||
Username string `gorm:"column:username;type:text;not null" json:"username"`
|
||||
Email sql_types.SqlString `gorm:"column:email;type:text" json:"email,omitempty"`
|
||||
Tags sql_types.SqlStringArray `gorm:"column:tags;type:text[];not null;default:'{}'" json:"tags"`
|
||||
CreatedAt sql_types.SqlTimeStamp `gorm:"column:created_at;type:timestamptz;not null;default:now()" json:"created_at"`
|
||||
}
|
||||
|
||||
func (ModelUser) TableName() string {
|
||||
return "public.users"
|
||||
}
|
||||
```
|
||||
|
||||
type ModelPost struct {
|
||||
ID int64 `gorm:"column:id;type:bigint;primaryKey" json:"id"`
|
||||
UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"`
|
||||
Title string `gorm:"column:title;type:varchar(200);not null" json:"title"`
|
||||
Content sql_types.SqlString `gorm:"column:content;type:text" json:"content,omitempty"`
|
||||
### Standard library — `--types stdlib`
|
||||
|
||||
// Belongs to
|
||||
Use *ModelUser `gorm:"foreignKey:UserID;references:ID" json:"use,omitempty"`
|
||||
```go
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ModelUser struct {
|
||||
ID string `gorm:"column:id;type:uuid;primaryKey" json:"id"`
|
||||
Username string `gorm:"column:username;type:text;not null" json:"username"`
|
||||
Email sql.NullString `gorm:"column:email;type:text" json:"email,omitempty"`
|
||||
Tags []string `gorm:"column:tags;type:text[];not null;default:'{}'" json:"tags"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;default:now()" json:"created_at"`
|
||||
}
|
||||
|
||||
func (ModelPost) TableName() string {
|
||||
return "public.posts"
|
||||
func (ModelUser) TableName() string {
|
||||
return "public.users"
|
||||
}
|
||||
```
|
||||
|
||||
## Writer Options
|
||||
|
||||
### NullableTypes
|
||||
|
||||
Controls which Go package is used for nullable column types. Set via the `--types` CLI flag or `WriterOptions.NullableTypes`:
|
||||
|
||||
```go
|
||||
// Use sqltypes package types
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "models.go",
|
||||
PackageName: "models",
|
||||
NullableTypes: writers.NullableTypeSqlTypes,
|
||||
}
|
||||
|
||||
// Use standard library database/sql types
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "models.go",
|
||||
PackageName: "models",
|
||||
NullableTypes: writers.NullableTypeStdlib,
|
||||
}
|
||||
```
|
||||
|
||||
### Metadata Options
|
||||
|
||||
Configure the writer behavior using metadata in `WriterOptions`:
|
||||
Configure additional writer behavior using metadata in `WriterOptions`:
|
||||
|
||||
```go
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "models.go",
|
||||
PackageName: "models",
|
||||
Metadata: map[string]interface{}{
|
||||
"multi_file": true, // Enable multi-file mode
|
||||
"populate_refs": true, // Populate RefDatabase/RefSchema
|
||||
Metadata: map[string]any{
|
||||
"multi_file": true, // Enable multi-file mode
|
||||
"populate_refs": true, // Populate RefDatabase/RefSchema
|
||||
"generate_get_id_str": true, // Generate GetIDStr() methods
|
||||
},
|
||||
}
|
||||
@@ -145,18 +176,23 @@ options := &writers.WriterOptions{
|
||||
|
||||
## Type Mapping
|
||||
|
||||
| SQL Type | Go Type | Notes |
|
||||
|----------|---------|-------|
|
||||
| bigint, int8 | int64 | - |
|
||||
| integer, int, int4 | int | - |
|
||||
| smallint, int2 | int16 | - |
|
||||
| varchar, text | string | Not nullable |
|
||||
| varchar, text (nullable) | sql_types.SqlString | Nullable |
|
||||
| boolean, bool | bool | - |
|
||||
| timestamp, timestamptz | time.Time | - |
|
||||
| numeric, decimal | float64 | - |
|
||||
| uuid | string | - |
|
||||
| json, jsonb | string | - |
|
||||
The nullable type package is selected with `--types` (or `WriterOptions.NullableTypes`).
|
||||
|
||||
| SQL Type | NOT NULL — both | Nullable — sqltypes | Nullable — stdlib |
|
||||
|---|---|---|---|
|
||||
| `bigint` | `int64` | `SqlInt64` | `sql.NullInt64` |
|
||||
| `integer` | `int32` | `SqlInt32` | `sql.NullInt32` |
|
||||
| `smallint` | `int16` | `SqlInt16` | `sql.NullInt16` |
|
||||
| `text`, `varchar` | `string` | `SqlString` | `sql.NullString` |
|
||||
| `boolean` | `bool` | `SqlBool` | `sql.NullBool` |
|
||||
| `timestamp`, `timestamptz` | `time.Time` | `SqlTimeStamp` | `sql.NullTime` |
|
||||
| `numeric`, `decimal` | `float64` | `SqlFloat64` | `sql.NullFloat64` |
|
||||
| `uuid` | `string` | `SqlUUID` | `sql.NullString` |
|
||||
| `jsonb` | `string` | `SqlString` | `sql.NullString` |
|
||||
| `text[]` | `SqlStringArray` | `SqlStringArray` | `[]string` |
|
||||
| `integer[]` | `SqlInt32Array` | `SqlInt32Array` | `[]int32` |
|
||||
| `uuid[]` | `SqlUUIDArray` | `SqlUUIDArray` | `[]string` |
|
||||
| `vector` | `SqlVector` | `SqlVector` | `[]float32` |
|
||||
|
||||
## Relationship Generation
|
||||
|
||||
@@ -170,7 +206,8 @@ The writer automatically generates relationship fields:
|
||||
## Notes
|
||||
|
||||
- Model names are prefixed with "Model" (e.g., `ModelUser`)
|
||||
- Nullable columns use `sql_types.SqlString`, `sql_types.SqlInt64`, etc.
|
||||
- Nullable columns use `sql_types.SqlString`, `sql_types.SqlInt64`, etc. by default; pass `--types stdlib` to use `sql.NullString`, `sql.NullInt64`, etc. instead
|
||||
- Array columns use `sql_types.SqlStringArray`, `sql_types.SqlInt32Array`, etc. by default; `--types stdlib` produces plain Go slices (`[]string`, `[]int32`, …)
|
||||
- Generated code is auto-formatted with `go fmt`
|
||||
- JSON tags are automatically added
|
||||
- Supports schema-qualified table names in `TableName()` method
|
||||
|
||||
@@ -2,6 +2,7 @@ package gorm
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
@@ -17,17 +18,20 @@ type TemplateData struct {
|
||||
|
||||
// ModelData represents a single model/struct in the template
|
||||
type ModelData struct {
|
||||
Name string
|
||||
TableName string // schema.table format
|
||||
SchemaName string
|
||||
TableNameOnly string // just table name without schema
|
||||
Comment string
|
||||
Fields []*FieldData
|
||||
Config *MethodConfig
|
||||
PrimaryKeyField string // Name of the primary key field
|
||||
PrimaryKeyType string // Go type of the primary key field
|
||||
IDColumnName string // Name of the ID column in database
|
||||
Prefix string // 3-letter prefix
|
||||
Name string
|
||||
TableName string // schema.table format
|
||||
SchemaName string
|
||||
TableNameOnly string // just table name without schema
|
||||
Comment string
|
||||
Fields []*FieldData
|
||||
Config *MethodConfig
|
||||
PrimaryKeyField string // Name of the primary key field
|
||||
PrimaryKeyType string // Go type of the primary key field
|
||||
PrimaryKeyIsSQL bool // Whether PK uses a SQL wrapper type
|
||||
PrimaryKeyIsStr bool // Whether helper methods should use string IDs
|
||||
PrimaryKeyIDType string // Helper method GetID/SetID/UpdateID type
|
||||
IDColumnName string // Name of the ID column in database
|
||||
Prefix string // 3-letter prefix
|
||||
}
|
||||
|
||||
// FieldData represents a single field in a struct
|
||||
@@ -136,7 +140,14 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper, fl
|
||||
// Sanitize column name to remove backticks
|
||||
safeName := writers.SanitizeStructTagValue(col.Name)
|
||||
model.PrimaryKeyField = SnakeCaseToPascalCase(safeName)
|
||||
model.PrimaryKeyType = typeMapper.SQLTypeToGoType(col.Type, col.NotNull)
|
||||
goType := typeMapper.SQLTypeToGoType(col.Type, col.NotNull)
|
||||
model.PrimaryKeyType = goType
|
||||
model.PrimaryKeyIsSQL = strings.Contains(goType, "sql_types.") || strings.Contains(goType, "sql.")
|
||||
model.PrimaryKeyIsStr = isStringLikePrimaryKeyType(goType)
|
||||
model.PrimaryKeyIDType = "int64"
|
||||
if model.PrimaryKeyIsStr {
|
||||
model.PrimaryKeyIDType = "string"
|
||||
}
|
||||
model.IDColumnName = safeName
|
||||
break
|
||||
}
|
||||
@@ -189,6 +200,15 @@ func formatComment(description, comment string) string {
|
||||
return comment
|
||||
}
|
||||
|
||||
func isStringLikePrimaryKeyType(goType string) bool {
|
||||
switch goType {
|
||||
case "string", "*string", "sql.NullString", "sql_types.SqlString", "sql_types.SqlUUID":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resolveFieldNameCollision checks if a field name conflicts with generated method names
|
||||
// and adds an underscore suffix if there's a collision
|
||||
func resolveFieldNameCollision(fieldName string) string {
|
||||
|
||||
@@ -43,26 +43,56 @@ func (m {{.Name}}) SchemaName() string {
|
||||
{{end}}
|
||||
{{if and .Config.GenerateGetID .PrimaryKeyField}}
|
||||
// GetID returns the primary key value
|
||||
func (m {{.Name}}) GetID() int64 {
|
||||
func (m {{.Name}}) GetID() {{.PrimaryKeyIDType}} {
|
||||
{{if .PrimaryKeyIsSQL -}}
|
||||
{{if .PrimaryKeyIsStr -}}
|
||||
return m.{{.PrimaryKeyField}}.String()
|
||||
{{- else -}}
|
||||
return m.{{.PrimaryKeyField}}.Int64()
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
{{if .PrimaryKeyIsStr -}}
|
||||
return m.{{.PrimaryKeyField}}
|
||||
{{- else -}}
|
||||
return int64(m.{{.PrimaryKeyField}})
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{end}}
|
||||
{{if and .Config.GenerateGetIDStr .PrimaryKeyField}}
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m {{.Name}}) GetIDStr() string {
|
||||
{{if .PrimaryKeyIsSQL -}}
|
||||
return m.{{.PrimaryKeyField}}.String()
|
||||
{{- else if .PrimaryKeyIsStr -}}
|
||||
return m.{{.PrimaryKeyField}}
|
||||
{{- else -}}
|
||||
return fmt.Sprintf("%d", m.{{.PrimaryKeyField}})
|
||||
{{- end}}
|
||||
}
|
||||
{{end}}
|
||||
{{if and .Config.GenerateSetID .PrimaryKeyField}}
|
||||
// SetID sets the primary key value
|
||||
func (m {{.Name}}) SetID(newid int64) {
|
||||
func (m {{.Name}}) SetID(newid {{.PrimaryKeyIDType}}) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
{{end}}
|
||||
{{if and .Config.GenerateUpdateID .PrimaryKeyField}}
|
||||
// UpdateID updates the primary key value
|
||||
func (m *{{.Name}}) UpdateID(newid int64) {
|
||||
func (m *{{.Name}}) UpdateID(newid {{.PrimaryKeyIDType}}) {
|
||||
{{if .PrimaryKeyIsSQL -}}
|
||||
{{if .PrimaryKeyIsStr -}}
|
||||
m.{{.PrimaryKeyField}}.FromString(newid)
|
||||
{{- else -}}
|
||||
m.{{.PrimaryKeyField}}.FromString(fmt.Sprintf("%d", newid))
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
{{if .PrimaryKeyIsStr -}}
|
||||
m.{{.PrimaryKeyField}} = newid
|
||||
{{- else -}}
|
||||
m.{{.PrimaryKeyField}} = {{.PrimaryKeyType}}(newid)
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{end}}
|
||||
{{if and .Config.GenerateGetIDName .IDColumnName}}
|
||||
|
||||
+198
-11
@@ -11,29 +11,49 @@ import (
|
||||
|
||||
// TypeMapper handles type conversions between SQL and Go types
|
||||
type TypeMapper struct {
|
||||
// Package alias for sql_types import
|
||||
sqlTypesAlias string
|
||||
typeStyle string // writers.NullableTypeSqlTypes | writers.NullableTypeStdlib | writers.NullableTypeBaselib
|
||||
}
|
||||
|
||||
// NewTypeMapper creates a new TypeMapper with default settings
|
||||
func NewTypeMapper() *TypeMapper {
|
||||
// NewTypeMapper creates a new TypeMapper.
|
||||
// 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: "sql_types",
|
||||
typeStyle: typeStyle,
|
||||
}
|
||||
}
|
||||
|
||||
// SQLTypeToGoType converts a SQL type to its Go equivalent
|
||||
// Handles nullable types using ResolveSpec sql_types package
|
||||
// SQLTypeToGoType converts a SQL type to its Go equivalent.
|
||||
func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string {
|
||||
// Normalize SQL type (lowercase, remove length/precision)
|
||||
// Array types are handled separately for both styles.
|
||||
if pgsql.IsArrayType(sqlType) {
|
||||
return tm.arrayGoType(tm.extractBaseType(sqlType))
|
||||
}
|
||||
|
||||
baseType := tm.extractBaseType(sqlType)
|
||||
|
||||
// If not null, use base Go types
|
||||
switch tm.typeStyle {
|
||||
case writers.NullableTypeStdlib:
|
||||
if notNull {
|
||||
return tm.rawGoType(baseType)
|
||||
}
|
||||
return tm.stdlibNullableGoType(baseType)
|
||||
case writers.NullableTypeBaselib:
|
||||
if notNull {
|
||||
return tm.rawGoType(baseType)
|
||||
}
|
||||
return tm.baselibNullableGoType(baseType)
|
||||
}
|
||||
|
||||
// sqltypes
|
||||
if notNull {
|
||||
return tm.baseGoType(baseType)
|
||||
}
|
||||
|
||||
// For nullable fields, use sql_types
|
||||
return tm.nullableGoType(baseType)
|
||||
}
|
||||
|
||||
@@ -106,6 +126,9 @@ func (tm *TypeMapper) baseGoType(sqlType string) string {
|
||||
|
||||
// Other
|
||||
"money": "float64",
|
||||
|
||||
// pgvector — always uses SqlVector even when NOT NULL
|
||||
"vector": tm.sqlTypesAlias + ".SqlVector",
|
||||
}
|
||||
|
||||
if goType, ok := typeMap[sqlType]; ok {
|
||||
@@ -179,6 +202,9 @@ func (tm *TypeMapper) nullableGoType(sqlType string) string {
|
||||
|
||||
// Other
|
||||
"money": tm.sqlTypesAlias + ".SqlFloat64",
|
||||
|
||||
// pgvector
|
||||
"vector": tm.sqlTypesAlias + ".SqlVector",
|
||||
}
|
||||
|
||||
if goType, ok := typeMap[sqlType]; ok {
|
||||
@@ -189,6 +215,155 @@ func (tm *TypeMapper) nullableGoType(sqlType string) string {
|
||||
return tm.sqlTypesAlias + ".SqlString"
|
||||
}
|
||||
|
||||
// arrayGoType returns the Go type for a PostgreSQL array column.
|
||||
// The baseElemType is the canonical base type (e.g. "text", "integer").
|
||||
func (tm *TypeMapper) arrayGoType(baseElemType string) string {
|
||||
if tm.typeStyle == writers.NullableTypeStdlib || tm.typeStyle == writers.NullableTypeBaselib {
|
||||
return tm.stdlibArrayGoType(baseElemType)
|
||||
}
|
||||
typeMap := map[string]string{
|
||||
"text": tm.sqlTypesAlias + ".SqlStringArray", "varchar": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"char": tm.sqlTypesAlias + ".SqlStringArray", "character": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"citext": tm.sqlTypesAlias + ".SqlStringArray", "bpchar": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"inet": tm.sqlTypesAlias + ".SqlStringArray", "cidr": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"macaddr": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"json": tm.sqlTypesAlias + ".SqlStringArray", "jsonb": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"integer": tm.sqlTypesAlias + ".SqlInt32Array", "int": tm.sqlTypesAlias + ".SqlInt32Array",
|
||||
"int4": tm.sqlTypesAlias + ".SqlInt32Array", "serial": tm.sqlTypesAlias + ".SqlInt32Array",
|
||||
"smallint": tm.sqlTypesAlias + ".SqlInt16Array", "int2": tm.sqlTypesAlias + ".SqlInt16Array",
|
||||
"smallserial": tm.sqlTypesAlias + ".SqlInt16Array",
|
||||
"bigint": tm.sqlTypesAlias + ".SqlInt64Array", "int8": tm.sqlTypesAlias + ".SqlInt64Array",
|
||||
"bigserial": tm.sqlTypesAlias + ".SqlInt64Array",
|
||||
"real": tm.sqlTypesAlias + ".SqlFloat32Array", "float4": tm.sqlTypesAlias + ".SqlFloat32Array",
|
||||
"double precision": tm.sqlTypesAlias + ".SqlFloat64Array", "float8": tm.sqlTypesAlias + ".SqlFloat64Array",
|
||||
"numeric": tm.sqlTypesAlias + ".SqlFloat64Array", "decimal": tm.sqlTypesAlias + ".SqlFloat64Array",
|
||||
"money": tm.sqlTypesAlias + ".SqlFloat64Array",
|
||||
"boolean": tm.sqlTypesAlias + ".SqlBoolArray", "bool": tm.sqlTypesAlias + ".SqlBoolArray",
|
||||
"uuid": tm.sqlTypesAlias + ".SqlUUIDArray",
|
||||
}
|
||||
if goType, ok := typeMap[baseElemType]; ok {
|
||||
return goType
|
||||
}
|
||||
return tm.sqlTypesAlias + ".SqlStringArray"
|
||||
}
|
||||
|
||||
// rawGoType returns the plain Go type for a NOT NULL column in stdlib mode.
|
||||
func (tm *TypeMapper) rawGoType(sqlType string) string {
|
||||
typeMap := map[string]string{
|
||||
"integer": "int32", "int": "int32", "int4": "int32", "serial": "int32",
|
||||
"smallint": "int16", "int2": "int16", "smallserial": "int16",
|
||||
"bigint": "int64", "int8": "int64", "bigserial": "int64",
|
||||
"boolean": "bool", "bool": "bool",
|
||||
"real": "float32", "float4": "float32",
|
||||
"double precision": "float64", "float8": "float64",
|
||||
"numeric": "float64", "decimal": "float64", "money": "float64",
|
||||
"text": "string", "varchar": "string", "char": "string",
|
||||
"character": "string", "citext": "string", "bpchar": "string",
|
||||
"inet": "string", "cidr": "string", "macaddr": "string",
|
||||
"uuid": "string", "json": "string", "jsonb": "string",
|
||||
"timestamp": "time.Time",
|
||||
"timestamp without time zone": "time.Time",
|
||||
"timestamp with time zone": "time.Time",
|
||||
"timestamptz": "time.Time",
|
||||
"date": "time.Time",
|
||||
"time": "time.Time",
|
||||
"time without time zone": "time.Time",
|
||||
"time with time zone": "time.Time",
|
||||
"timetz": "time.Time",
|
||||
"bytea": "[]byte",
|
||||
"vector": "[]float32",
|
||||
}
|
||||
if goType, ok := typeMap[sqlType]; ok {
|
||||
return goType
|
||||
}
|
||||
return "string"
|
||||
}
|
||||
|
||||
// stdlibNullableGoType returns the database/sql nullable type for a column.
|
||||
func (tm *TypeMapper) stdlibNullableGoType(sqlType string) string {
|
||||
typeMap := map[string]string{
|
||||
"integer": "sql.NullInt32", "int": "sql.NullInt32", "int4": "sql.NullInt32", "serial": "sql.NullInt32",
|
||||
"smallint": "sql.NullInt16", "int2": "sql.NullInt16", "smallserial": "sql.NullInt16",
|
||||
"bigint": "sql.NullInt64", "int8": "sql.NullInt64", "bigserial": "sql.NullInt64",
|
||||
"boolean": "sql.NullBool", "bool": "sql.NullBool",
|
||||
"real": "sql.NullFloat64", "float4": "sql.NullFloat64",
|
||||
"double precision": "sql.NullFloat64", "float8": "sql.NullFloat64",
|
||||
"numeric": "sql.NullFloat64", "decimal": "sql.NullFloat64", "money": "sql.NullFloat64",
|
||||
"text": "sql.NullString", "varchar": "sql.NullString", "char": "sql.NullString",
|
||||
"character": "sql.NullString", "citext": "sql.NullString", "bpchar": "sql.NullString",
|
||||
"inet": "sql.NullString", "cidr": "sql.NullString", "macaddr": "sql.NullString",
|
||||
"uuid": "sql.NullString", "json": "sql.NullString", "jsonb": "sql.NullString",
|
||||
"timestamp": "sql.NullTime",
|
||||
"timestamp without time zone": "sql.NullTime",
|
||||
"timestamp with time zone": "sql.NullTime",
|
||||
"timestamptz": "sql.NullTime",
|
||||
"date": "sql.NullTime",
|
||||
"time": "sql.NullTime",
|
||||
"time without time zone": "sql.NullTime",
|
||||
"time with time zone": "sql.NullTime",
|
||||
"timetz": "sql.NullTime",
|
||||
"bytea": "[]byte",
|
||||
"vector": "[]float32",
|
||||
}
|
||||
if goType, ok := typeMap[sqlType]; ok {
|
||||
return goType
|
||||
}
|
||||
return "sql.NullString"
|
||||
}
|
||||
|
||||
// baselibNullableGoType returns plain Go pointer types for nullable columns.
|
||||
func (tm *TypeMapper) baselibNullableGoType(sqlType string) string {
|
||||
typeMap := map[string]string{
|
||||
"integer": "*int32", "int": "*int32", "int4": "*int32", "serial": "*int32",
|
||||
"smallint": "*int16", "int2": "*int16", "smallserial": "*int16",
|
||||
"bigint": "*int64", "int8": "*int64", "bigserial": "*int64",
|
||||
"boolean": "*bool", "bool": "*bool",
|
||||
"real": "*float32", "float4": "*float32",
|
||||
"double precision": "*float64", "float8": "*float64",
|
||||
"numeric": "*float64", "decimal": "*float64", "money": "*float64",
|
||||
"text": "*string", "varchar": "*string", "char": "*string",
|
||||
"character": "*string", "citext": "*string", "bpchar": "*string",
|
||||
"inet": "*string", "cidr": "*string", "macaddr": "*string",
|
||||
"uuid": "*string", "json": "*string", "jsonb": "*string",
|
||||
"timestamp": "*time.Time",
|
||||
"timestamp without time zone": "*time.Time",
|
||||
"timestamp with time zone": "*time.Time",
|
||||
"timestamptz": "*time.Time",
|
||||
"date": "*time.Time",
|
||||
"time": "*time.Time",
|
||||
"time without time zone": "*time.Time",
|
||||
"time with time zone": "*time.Time",
|
||||
"timetz": "*time.Time",
|
||||
"bytea": "[]byte",
|
||||
"vector": "[]float32",
|
||||
}
|
||||
if goType, ok := typeMap[sqlType]; ok {
|
||||
return goType
|
||||
}
|
||||
return "*string"
|
||||
}
|
||||
|
||||
// stdlibArrayGoType returns a plain Go slice type for array columns in stdlib mode.
|
||||
func (tm *TypeMapper) stdlibArrayGoType(baseElemType string) string {
|
||||
typeMap := map[string]string{
|
||||
"text": "[]string", "varchar": "[]string", "char": "[]string",
|
||||
"character": "[]string", "citext": "[]string", "bpchar": "[]string",
|
||||
"inet": "[]string", "cidr": "[]string", "macaddr": "[]string",
|
||||
"uuid": "[]string", "json": "[]string", "jsonb": "[]string",
|
||||
"integer": "[]int32", "int": "[]int32", "int4": "[]int32", "serial": "[]int32",
|
||||
"smallint": "[]int16", "int2": "[]int16", "smallserial": "[]int16",
|
||||
"bigint": "[]int64", "int8": "[]int64", "bigserial": "[]int64",
|
||||
"real": "[]float32", "float4": "[]float32",
|
||||
"double precision": "[]float64", "float8": "[]float64",
|
||||
"numeric": "[]float64", "decimal": "[]float64", "money": "[]float64",
|
||||
"boolean": "[]bool", "bool": "[]bool",
|
||||
}
|
||||
if goType, ok := typeMap[baseElemType]; ok {
|
||||
return goType
|
||||
}
|
||||
return "[]string"
|
||||
}
|
||||
|
||||
// BuildGormTag generates a complete GORM tag string for a column
|
||||
func (tm *TypeMapper) BuildGormTag(column *models.Column, table *models.Table) string {
|
||||
var parts []string
|
||||
@@ -330,7 +505,19 @@ func (tm *TypeMapper) NeedsFmtImport(generateGetIDStr bool) bool {
|
||||
return generateGetIDStr
|
||||
}
|
||||
|
||||
// GetSQLTypesImport returns the import path for sql_types
|
||||
// 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
|
||||
// package (ready to pass to AddImport). Returns empty string when no import is needed.
|
||||
func (tm *TypeMapper) GetNullableTypeImportLine() string {
|
||||
switch tm.typeStyle {
|
||||
case writers.NullableTypeStdlib:
|
||||
return "\"database/sql\""
|
||||
case writers.NullableTypeBaselib:
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s \"%s\"", tm.sqlTypesAlias, tm.GetSQLTypesImport())
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ type Writer struct {
|
||||
func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
w := &Writer{
|
||||
options: options,
|
||||
typeMapper: NewTypeMapper(),
|
||||
typeMapper: NewTypeMapper(options.NullableTypes),
|
||||
config: LoadMethodConfigFromMetadata(options.Metadata),
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
|
||||
packageName := w.getPackageName()
|
||||
templateData := NewTemplateData(packageName, w.config)
|
||||
|
||||
// Add sql_types import (always needed for nullable types)
|
||||
templateData.AddImport(fmt.Sprintf("sql_types \"%s\"", w.typeMapper.GetSQLTypesImport()))
|
||||
// Add nullable types import (sqltypes or stdlib depending on options)
|
||||
templateData.AddImport(w.typeMapper.GetNullableTypeImportLine())
|
||||
|
||||
// Collect all models
|
||||
for _, schema := range db.Schemas {
|
||||
@@ -99,8 +99,8 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Add fmt import if GetIDStr is enabled
|
||||
if w.config.GenerateGetIDStr {
|
||||
// Add fmt import when generated helper methods need string formatting.
|
||||
if w.needsFmtImport(templateData.Models) {
|
||||
templateData.AddImport("\"fmt\"")
|
||||
}
|
||||
|
||||
@@ -171,8 +171,8 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
|
||||
// Create template data for this single table
|
||||
templateData := NewTemplateData(packageName, w.config)
|
||||
|
||||
// Add sql_types import
|
||||
templateData.AddImport(fmt.Sprintf("sql_types \"%s\"", w.typeMapper.GetSQLTypesImport()))
|
||||
// Add nullable types import (sqltypes or stdlib depending on options)
|
||||
templateData.AddImport(w.typeMapper.GetNullableTypeImportLine())
|
||||
|
||||
// Create model data
|
||||
modelData := NewModelData(table, schema.Name, w.typeMapper, w.options.FlattenSchema)
|
||||
@@ -189,8 +189,8 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Add fmt import if GetIDStr is enabled
|
||||
if w.config.GenerateGetIDStr {
|
||||
// Add fmt import when generated helper methods need string formatting.
|
||||
if w.needsFmtImport(templateData.Models) {
|
||||
templateData.AddImport("\"fmt\"")
|
||||
}
|
||||
|
||||
@@ -295,6 +295,26 @@ func (w *Writer) addRelationshipFields(modelData *ModelData, table *models.Table
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) needsFmtImport(models []*ModelData) bool {
|
||||
if w.config.GenerateGetIDStr {
|
||||
for _, model := range models {
|
||||
if model.PrimaryKeyField != "" && !model.PrimaryKeyIsSQL && !model.PrimaryKeyIsStr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if w.config.GenerateUpdateID {
|
||||
for _, model := range models {
|
||||
if model.PrimaryKeyField != "" && model.PrimaryKeyIsSQL && !model.PrimaryKeyIsStr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// findTable finds a table by schema and name in the database
|
||||
func (w *Writer) findTable(schemaName, tableName string, db *models.Database) *models.Table {
|
||||
for _, schema := range db.Schemas {
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestWriter_WriteTable(t *testing.T) {
|
||||
"ID",
|
||||
"int64",
|
||||
"Email",
|
||||
"sql_types.SqlString",
|
||||
"*string",
|
||||
"CreatedAt",
|
||||
"time.Time",
|
||||
"gorm:\"column:id",
|
||||
@@ -598,6 +598,55 @@ func TestWriter_UpdateIDTypeSafety(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_StringPrimaryKeyHelpers_Gorm(t *testing.T) {
|
||||
table := models.InitTable("accounts", "public")
|
||||
table.Columns["id"] = &models.Column{
|
||||
Name: "id",
|
||||
Type: "uuid",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
opts := &writers.WriterOptions{
|
||||
PackageName: "models",
|
||||
OutputPath: filepath.Join(tmpDir, "test.go"),
|
||||
}
|
||||
|
||||
writer := NewWriter(opts)
|
||||
err := writer.WriteTable(table)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteTable failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(opts.OutputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read generated file: %v", err)
|
||||
}
|
||||
|
||||
generated := string(content)
|
||||
|
||||
expectations := []string{
|
||||
"ID string",
|
||||
"func (m ModelPublicAccounts) GetID() string",
|
||||
"return m.ID",
|
||||
"func (m ModelPublicAccounts) GetIDStr() string",
|
||||
"func (m ModelPublicAccounts) SetID(newid string)",
|
||||
"func (m *ModelPublicAccounts) UpdateID(newid string)",
|
||||
"m.ID = newid",
|
||||
}
|
||||
|
||||
for _, expected := range expectations {
|
||||
if !strings.Contains(generated, expected) {
|
||||
t.Errorf("Generated code missing expected content: %q\nGenerated:\n%s", expected, generated)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(generated, "GetID() int64") || strings.Contains(generated, "UpdateID(newid int64)") {
|
||||
t.Errorf("String primary keys should not use int64 helper signatures\nGenerated:\n%s", generated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameConverter_SnakeCaseToPascalCase(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
@@ -643,7 +692,7 @@ func TestNameConverter_Pluralize(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTypeMapper_SQLTypeToGoType(t *testing.T) {
|
||||
mapper := NewTypeMapper()
|
||||
mapper := NewTypeMapper("")
|
||||
|
||||
tests := []struct {
|
||||
sqlType string
|
||||
@@ -651,13 +700,17 @@ func TestTypeMapper_SQLTypeToGoType(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{"bigint", true, "int64"},
|
||||
{"bigint", false, "sql_types.SqlInt64"},
|
||||
{"bigint", false, "*int64"},
|
||||
{"varchar", true, "string"},
|
||||
{"varchar", false, "sql_types.SqlString"},
|
||||
{"varchar", false, "*string"},
|
||||
{"timestamp", true, "time.Time"},
|
||||
{"timestamp", false, "sql_types.SqlTimeStamp"},
|
||||
{"timestamp", false, "*time.Time"},
|
||||
{"boolean", true, "bool"},
|
||||
{"boolean", false, "sql_types.SqlBool"},
|
||||
{"boolean", false, "*bool"},
|
||||
{"text[]", true, "[]string"},
|
||||
{"text[]", false, "[]string"},
|
||||
{"integer[]", true, "[]int32"},
|
||||
{"bigint[]", false, "[]int64"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -670,8 +723,23 @@ func TestTypeMapper_SQLTypeToGoType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeMapper_BuildGormTag_ArrayType(t *testing.T) {
|
||||
mapper := NewTypeMapper("")
|
||||
|
||||
col := &models.Column{
|
||||
Name: "tags",
|
||||
Type: "text[]",
|
||||
NotNull: false,
|
||||
}
|
||||
|
||||
tag := mapper.BuildGormTag(col, nil)
|
||||
if !strings.Contains(tag, "type:text[]") {
|
||||
t.Fatalf("expected array type to be preserved, got %q", tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeMapper_BuildGormTag_PreservesExplicitTypeModifiers(t *testing.T) {
|
||||
mapper := NewTypeMapper()
|
||||
mapper := NewTypeMapper("")
|
||||
|
||||
col := &models.Column{
|
||||
Name: "embedding",
|
||||
|
||||
@@ -31,6 +31,10 @@ type MigrationWriter struct {
|
||||
|
||||
// NewMigrationWriter creates a new templated migration writer
|
||||
func NewMigrationWriter(options *writers.WriterOptions) (*MigrationWriter, error) {
|
||||
if options == nil {
|
||||
options = &writers.WriterOptions{}
|
||||
}
|
||||
|
||||
executor, err := NewTemplateExecutor(options.FlattenSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create template executor: %w", err)
|
||||
@@ -44,6 +48,16 @@ func NewMigrationWriter(options *writers.WriterOptions) (*MigrationWriter, error
|
||||
|
||||
// WriteMigration generates migration scripts using templates
|
||||
func (w *MigrationWriter) WriteMigration(model *models.Database, current *models.Database) error {
|
||||
if model == nil {
|
||||
return fmt.Errorf("model database is required")
|
||||
}
|
||||
if w.options == nil {
|
||||
w.options = &writers.WriterOptions{}
|
||||
}
|
||||
if current == nil {
|
||||
current = models.InitDatabase(model.Name)
|
||||
}
|
||||
|
||||
var writer io.Writer
|
||||
var file *os.File
|
||||
var err error
|
||||
@@ -86,9 +100,16 @@ func (w *MigrationWriter) WriteMigration(model *models.Database, current *models
|
||||
|
||||
// Process each schema in the model
|
||||
for _, modelSchema := range model.Schemas {
|
||||
if modelSchema == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find corresponding schema in current database
|
||||
var currentSchema *models.Schema
|
||||
for _, cs := range current.Schemas {
|
||||
if cs == nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(cs.Name, modelSchema.Name) {
|
||||
currentSchema = cs
|
||||
break
|
||||
@@ -123,7 +144,11 @@ func (w *MigrationWriter) WriteMigration(model *models.Database, current *models
|
||||
// Write header
|
||||
fmt.Fprintf(w.writer, "-- PostgreSQL Migration Script\n")
|
||||
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n")
|
||||
fmt.Fprintf(w.writer, "-- Source: %s -> %s\n\n", current.Name, model.Name)
|
||||
fmt.Fprintf(w.writer, "-- Source: %s -> %s\n", current.Name, model.Name)
|
||||
if w.options.ContinueOnError {
|
||||
fmt.Fprintf(w.writer, "\\set ON_ERROR_STOP off\n")
|
||||
}
|
||||
fmt.Fprintf(w.writer, "\n")
|
||||
|
||||
// Write scripts
|
||||
for _, script := range scripts {
|
||||
@@ -139,13 +164,26 @@ func (w *MigrationWriter) WriteMigration(model *models.Database, current *models
|
||||
func (w *MigrationWriter) generateSchemaScripts(model *models.Schema, current *models.Schema) ([]MigrationScript, error) {
|
||||
scripts := make([]MigrationScript, 0)
|
||||
|
||||
// Phase 1: Drop constraints and indexes that changed (Priority 11-50)
|
||||
if schemaRequiresPGTrgm(model) {
|
||||
scripts = append(scripts, MigrationScript{
|
||||
ObjectName: "extension.pg_trgm",
|
||||
ObjectType: "create extension",
|
||||
Schema: model.Name,
|
||||
Priority: 80,
|
||||
Sequence: len(scripts),
|
||||
Body: "CREATE EXTENSION IF NOT EXISTS pg_trgm;",
|
||||
})
|
||||
}
|
||||
|
||||
// Phase 1: Drop constraints and indexes that changed (Priority 5-50)
|
||||
var droppedFKs map[string]bool
|
||||
if current != nil {
|
||||
dropScripts, err := w.generateDropScripts(model, current)
|
||||
dropScripts, dropped, err := w.generateDropScripts(model, current)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate drop scripts: %w", err)
|
||||
}
|
||||
scripts = append(scripts, dropScripts...)
|
||||
droppedFKs = dropped
|
||||
}
|
||||
|
||||
// Phase 3: Create/Alter tables and columns (Priority 100-145)
|
||||
@@ -163,7 +201,7 @@ func (w *MigrationWriter) generateSchemaScripts(model *models.Schema, current *m
|
||||
scripts = append(scripts, indexScripts...)
|
||||
|
||||
// Phase 5: Create foreign keys (Priority 195)
|
||||
fkScripts, err := w.generateForeignKeyScripts(model, current)
|
||||
fkScripts, err := w.generateForeignKeyScripts(model, current, droppedFKs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate foreign key scripts: %w", err)
|
||||
}
|
||||
@@ -179,9 +217,12 @@ func (w *MigrationWriter) generateSchemaScripts(model *models.Schema, current *m
|
||||
return scripts, nil
|
||||
}
|
||||
|
||||
// generateDropScripts generates DROP scripts using templates
|
||||
func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *models.Schema) ([]MigrationScript, error) {
|
||||
// generateDropScripts generates DROP scripts using templates.
|
||||
// Returns the scripts and a set of FK constraint keys (schema.table.name) that were
|
||||
// explicitly dropped because their referenced PK was being dropped, so they can be force-recreated.
|
||||
func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *models.Schema) ([]MigrationScript, map[string]bool, error) {
|
||||
scripts := make([]MigrationScript, 0)
|
||||
droppedFKs := make(map[string]bool)
|
||||
|
||||
// Build map of model tables for quick lookup
|
||||
modelTables := make(map[string]*models.Table)
|
||||
@@ -208,6 +249,44 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
|
||||
shouldDrop = true
|
||||
}
|
||||
|
||||
if shouldDrop && currentConstraint.Type == models.PrimaryKeyConstraint {
|
||||
// Drop FK constraints that depend on this PK before dropping the PK itself.
|
||||
for _, otherTable := range current.Tables {
|
||||
for fkName, fkConstraint := range otherTable.Constraints {
|
||||
if fkConstraint.Type != models.ForeignKeyConstraint {
|
||||
continue
|
||||
}
|
||||
refTable := fkConstraint.ReferencedTable
|
||||
refSchema := fkConstraint.ReferencedSchema
|
||||
if refSchema == "" {
|
||||
refSchema = current.Name
|
||||
}
|
||||
if strings.EqualFold(refTable, currentTable.Name) && strings.EqualFold(refSchema, current.Name) {
|
||||
fkKey := fmt.Sprintf("%s.%s.%s", current.Name, otherTable.Name, fkName)
|
||||
if !droppedFKs[fkKey] {
|
||||
droppedFKs[fkKey] = true
|
||||
sql, err := w.executor.ExecuteDropConstraint(DropConstraintData{
|
||||
SchemaName: current.Name,
|
||||
TableName: otherTable.Name,
|
||||
ConstraintName: fkName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
scripts = append(scripts, MigrationScript{
|
||||
ObjectName: fkKey,
|
||||
ObjectType: "drop constraint",
|
||||
Schema: current.Name,
|
||||
Priority: 5,
|
||||
Sequence: len(scripts),
|
||||
Body: sql,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if shouldDrop {
|
||||
sql, err := w.executor.ExecuteDropConstraint(DropConstraintData{
|
||||
SchemaName: current.Name,
|
||||
@@ -215,7 +294,7 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
|
||||
ConstraintName: constraintName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
script := MigrationScript{
|
||||
@@ -247,7 +326,7 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
|
||||
IndexName: indexName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
script := MigrationScript{
|
||||
@@ -263,7 +342,7 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
|
||||
}
|
||||
}
|
||||
|
||||
return scripts, nil
|
||||
return scripts, droppedFKs, nil
|
||||
}
|
||||
|
||||
// generateTableScripts generates CREATE/ALTER TABLE scripts using templates
|
||||
@@ -329,14 +408,18 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
|
||||
// Column doesn't exist, add it
|
||||
defaultVal := ""
|
||||
if modelCol.Default != nil {
|
||||
defaultVal = fmt.Sprintf("%v", modelCol.Default)
|
||||
if value, ok := modelCol.Default.(string); ok {
|
||||
defaultVal = writers.QuoteDefaultValue(value, modelCol.Type)
|
||||
} else {
|
||||
defaultVal = fmt.Sprintf("%v", modelCol.Default)
|
||||
}
|
||||
}
|
||||
|
||||
sql, err := w.executor.ExecuteAddColumn(AddColumnData{
|
||||
SchemaName: schema.Name,
|
||||
TableName: modelTable.Name,
|
||||
ColumnName: modelCol.Name,
|
||||
ColumnType: pgsql.ConvertSQLType(modelCol.Type),
|
||||
ColumnType: effectiveColumnSQLType(modelCol),
|
||||
Default: defaultVal,
|
||||
NotNull: modelCol.NotNull,
|
||||
})
|
||||
@@ -355,12 +438,13 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
|
||||
scripts = append(scripts, script)
|
||||
} else if !columnsEqual(modelCol, currentCol) {
|
||||
// Column exists but properties changed
|
||||
if modelCol.Type != currentCol.Type {
|
||||
if !columnTypesEqual(modelCol, currentCol) {
|
||||
sql, err := w.executor.ExecuteAlterColumnType(AlterColumnTypeData{
|
||||
SchemaName: schema.Name,
|
||||
TableName: modelTable.Name,
|
||||
ColumnName: modelCol.Name,
|
||||
NewType: pgsql.ConvertSQLType(modelCol.Type),
|
||||
NewType: effectiveAlterColumnSQLType(modelCol),
|
||||
UsingExpr: buildAlterColumnUsingExpression(modelCol.Name, effectiveAlterColumnSQLType(modelCol)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -382,7 +466,11 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
|
||||
setDefault := modelCol.Default != nil
|
||||
defaultVal := ""
|
||||
if setDefault {
|
||||
defaultVal = fmt.Sprintf("%v", modelCol.Default)
|
||||
if value, ok := modelCol.Default.(string); ok {
|
||||
defaultVal = writers.QuoteDefaultValue(value, modelCol.Type)
|
||||
} else {
|
||||
defaultVal = fmt.Sprintf("%v", modelCol.Default)
|
||||
}
|
||||
}
|
||||
|
||||
sql, err := w.executor.ExecuteAlterColumnDefault(AlterColumnDefaultData{
|
||||
@@ -537,12 +625,17 @@ func (w *MigrationWriter) generateIndexScripts(model *models.Schema, current *mo
|
||||
indexType = modelIndex.Type
|
||||
}
|
||||
|
||||
columnExprs := buildIndexColumnExpressions(modelTable, modelIndex, indexType)
|
||||
if len(columnExprs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sql, err := w.executor.ExecuteCreateIndex(CreateIndexData{
|
||||
SchemaName: model.Name,
|
||||
TableName: modelTable.Name,
|
||||
IndexName: indexName,
|
||||
IndexType: indexType,
|
||||
Columns: strings.Join(modelIndex.Columns, ", "),
|
||||
Columns: strings.Join(columnExprs, ", "),
|
||||
Unique: modelIndex.Unique,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -565,8 +658,30 @@ func (w *MigrationWriter) generateIndexScripts(model *models.Schema, current *mo
|
||||
return scripts, nil
|
||||
}
|
||||
|
||||
// generateForeignKeyScripts generates ADD CONSTRAINT FOREIGN KEY scripts using templates
|
||||
func (w *MigrationWriter) generateForeignKeyScripts(model *models.Schema, current *models.Schema) ([]MigrationScript, error) {
|
||||
func buildIndexColumnExpressions(table *models.Table, index *models.Index, indexType string) []string {
|
||||
columnExprs := make([]string, 0, len(index.Columns))
|
||||
for _, colName := range index.Columns {
|
||||
colExpr := colName
|
||||
if table != nil {
|
||||
if col, ok := resolveIndexColumn(table, colName); ok && col != nil {
|
||||
colExpr = col.SQLName()
|
||||
if strings.EqualFold(indexType, "gin") {
|
||||
opClass := ginOperatorClassForColumn(col, index.Comment)
|
||||
if opClass != "" {
|
||||
colExpr = fmt.Sprintf("%s %s", col.SQLName(), opClass)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
columnExprs = append(columnExprs, colExpr)
|
||||
}
|
||||
return columnExprs
|
||||
}
|
||||
|
||||
// generateForeignKeyScripts generates ADD CONSTRAINT FOREIGN KEY scripts using templates.
|
||||
// forceRecreate is a set of FK constraint keys (schema.table.name) that must be recreated
|
||||
// even if unchanged, because their referenced PK was dropped and recreated.
|
||||
func (w *MigrationWriter) generateForeignKeyScripts(model *models.Schema, current *models.Schema, forceRecreate map[string]bool) ([]MigrationScript, error) {
|
||||
scripts := make([]MigrationScript, 0)
|
||||
|
||||
// Build map of current tables
|
||||
@@ -587,13 +702,16 @@ func (w *MigrationWriter) generateForeignKeyScripts(model *models.Schema, curren
|
||||
continue
|
||||
}
|
||||
|
||||
shouldCreate := true
|
||||
fkKey := fmt.Sprintf("%s.%s.%s", model.Name, modelTable.Name, constraintName)
|
||||
shouldCreate := forceRecreate[fkKey]
|
||||
|
||||
if currentTable != nil {
|
||||
if currentConstraint, exists := currentTable.Constraints[constraintName]; exists {
|
||||
if constraintsEqual(constraint, currentConstraint) {
|
||||
shouldCreate = false
|
||||
}
|
||||
if !shouldCreate {
|
||||
if currentTable == nil {
|
||||
shouldCreate = true
|
||||
} else if currentConstraint, exists := currentTable.Constraints[constraintName]; !exists {
|
||||
shouldCreate = true
|
||||
} else if !constraintsEqual(constraint, currentConstraint) {
|
||||
shouldCreate = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -820,11 +938,21 @@ func columnsEqual(col1, col2 *models.Column) bool {
|
||||
if col1 == nil || col2 == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(col1.Type, col2.Type) &&
|
||||
return columnTypesEqual(col1, col2) &&
|
||||
col1.NotNull == col2.NotNull &&
|
||||
fmt.Sprintf("%v", col1.Default) == fmt.Sprintf("%v", col2.Default)
|
||||
}
|
||||
|
||||
func columnTypesEqual(col1, col2 *models.Column) bool {
|
||||
if col1 == nil || col2 == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(
|
||||
pgsql.NormalizeEquivalentSQLType(effectiveColumnSQLType(col1)),
|
||||
pgsql.NormalizeEquivalentSQLType(effectiveColumnSQLType(col2)),
|
||||
)
|
||||
}
|
||||
|
||||
// constraintsEqual checks if two constraints are equal
|
||||
func constraintsEqual(c1, c2 *models.Constraint) bool {
|
||||
if c1 == nil || c2 == nil {
|
||||
|
||||
@@ -57,6 +57,410 @@ func TestWriteMigration_NewTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMigration_ArrayDefault(t *testing.T) {
|
||||
current := models.InitDatabase("testdb")
|
||||
currentSchema := models.InitSchema("public")
|
||||
current.Schemas = append(current.Schemas, currentSchema)
|
||||
|
||||
model := models.InitDatabase("testdb")
|
||||
modelSchema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("plans", "public")
|
||||
|
||||
tagsCol := models.InitColumn("tags", "plans", "public")
|
||||
tagsCol.Type = "text[]"
|
||||
tagsCol.NotNull = true
|
||||
tagsCol.Default = "''{}''"
|
||||
table.Columns["tags"] = tagsCol
|
||||
|
||||
modelSchema.Tables = append(modelSchema.Tables, table)
|
||||
model.Schemas = append(model.Schemas, modelSchema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer, err := NewMigrationWriter(&writers.WriterOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create writer: %v", err)
|
||||
}
|
||||
writer.writer = &buf
|
||||
|
||||
err = writer.WriteMigration(model, current)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteMigration failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "tags text[] DEFAULT '{}' NOT NULL") {
|
||||
t.Fatalf("expected normalized array default in migration, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "'''{}'''") {
|
||||
t.Fatalf("migration still contains triple-quoted array default:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMigration_AltersColumnTypeWhenActualTypeDiffers(t *testing.T) {
|
||||
current := models.InitDatabase("testdb")
|
||||
currentSchema := models.InitSchema("public")
|
||||
currentTable := models.InitTable("learnings", "public")
|
||||
currentDetails := models.InitColumn("details", "learnings", "public")
|
||||
currentDetails.Type = "jsonb"
|
||||
currentTable.Columns["details"] = currentDetails
|
||||
currentSchema.Tables = append(currentSchema.Tables, currentTable)
|
||||
current.Schemas = append(current.Schemas, currentSchema)
|
||||
|
||||
model := models.InitDatabase("testdb")
|
||||
modelSchema := models.InitSchema("public")
|
||||
modelTable := models.InitTable("learnings", "public")
|
||||
modelDetails := models.InitColumn("details", "learnings", "public")
|
||||
modelDetails.Type = "text"
|
||||
modelTable.Columns["details"] = modelDetails
|
||||
modelSchema.Tables = append(modelSchema.Tables, modelTable)
|
||||
model.Schemas = append(model.Schemas, modelSchema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer, err := NewMigrationWriter(&writers.WriterOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create writer: %v", err)
|
||||
}
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteMigration(model, current); err != nil {
|
||||
t.Fatalf("WriteMigration failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "ALTER TABLE public.learnings") || !strings.Contains(output, "ALTER COLUMN details TYPE text") {
|
||||
t.Fatalf("expected migration to alter mismatched column type, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, `ALTER COLUMN details TYPE text USING details::text;`) {
|
||||
t.Fatalf("expected migration type alter to include USING cast, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMigration_UsesStorageTypeForSerialAlterStatements(t *testing.T) {
|
||||
current := models.InitDatabase("testdb")
|
||||
currentSchema := models.InitSchema("public")
|
||||
currentTable := models.InitTable("learnings", "public")
|
||||
currentID := models.InitColumn("id", "learnings", "public")
|
||||
currentID.Type = "uuid"
|
||||
currentTable.Columns["id"] = currentID
|
||||
currentSchema.Tables = append(currentSchema.Tables, currentTable)
|
||||
current.Schemas = append(current.Schemas, currentSchema)
|
||||
|
||||
model := models.InitDatabase("testdb")
|
||||
modelSchema := models.InitSchema("public")
|
||||
modelTable := models.InitTable("learnings", "public")
|
||||
modelID := models.InitColumn("id", "learnings", "public")
|
||||
modelID.Type = "bigserial"
|
||||
modelTable.Columns["id"] = modelID
|
||||
modelSchema.Tables = append(modelSchema.Tables, modelTable)
|
||||
model.Schemas = append(model.Schemas, modelSchema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer, err := NewMigrationWriter(&writers.WriterOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create writer: %v", err)
|
||||
}
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteMigration(model, current); err != nil {
|
||||
t.Fatalf("WriteMigration failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "ALTER COLUMN id TYPE bigint") {
|
||||
t.Fatalf("expected serial alter to use bigint storage type, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "ALTER COLUMN id TYPE bigserial;") {
|
||||
t.Fatalf("did not expect invalid bigserial alter statement, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, `ALTER COLUMN id TYPE bigint USING id::bigint;`) {
|
||||
t.Fatalf("expected serial alter to include USING cast, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMigration_ArrayAlterIncludesUsingCast(t *testing.T) {
|
||||
current := models.InitDatabase("testdb")
|
||||
currentSchema := models.InitSchema("public")
|
||||
currentTable := models.InitTable("learnings", "public")
|
||||
currentTags := models.InitColumn("tags", "learnings", "public")
|
||||
currentTags.Type = "text"
|
||||
currentTable.Columns["tags"] = currentTags
|
||||
currentSchema.Tables = append(currentSchema.Tables, currentTable)
|
||||
current.Schemas = append(current.Schemas, currentSchema)
|
||||
|
||||
model := models.InitDatabase("testdb")
|
||||
modelSchema := models.InitSchema("public")
|
||||
modelTable := models.InitTable("learnings", "public")
|
||||
modelTags := models.InitColumn("tags", "learnings", "public")
|
||||
modelTags.Type = "text[]"
|
||||
modelTable.Columns["tags"] = modelTags
|
||||
modelSchema.Tables = append(modelSchema.Tables, modelTable)
|
||||
model.Schemas = append(model.Schemas, modelSchema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer, err := NewMigrationWriter(&writers.WriterOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create writer: %v", err)
|
||||
}
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteMigration(model, current); err != nil {
|
||||
t.Fatalf("WriteMigration failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, `ALTER COLUMN tags TYPE text[] USING tags::text[];`) {
|
||||
t.Fatalf("expected array alter to include USING cast, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMigration_DoesNotAlterEquivalentNormalizedColumnType(t *testing.T) {
|
||||
current := models.InitDatabase("testdb")
|
||||
currentSchema := models.InitSchema("public")
|
||||
currentTable := models.InitTable("users", "public")
|
||||
currentEmail := models.InitColumn("email", "users", "public")
|
||||
currentEmail.Type = "character varying"
|
||||
currentEmail.Length = 255
|
||||
currentTable.Columns["email"] = currentEmail
|
||||
currentSchema.Tables = append(currentSchema.Tables, currentTable)
|
||||
current.Schemas = append(current.Schemas, currentSchema)
|
||||
|
||||
model := models.InitDatabase("testdb")
|
||||
modelSchema := models.InitSchema("public")
|
||||
modelTable := models.InitTable("users", "public")
|
||||
modelEmail := models.InitColumn("email", "users", "public")
|
||||
modelEmail.Type = "varchar(255)"
|
||||
modelTable.Columns["email"] = modelEmail
|
||||
modelSchema.Tables = append(modelSchema.Tables, modelTable)
|
||||
model.Schemas = append(model.Schemas, modelSchema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer, err := NewMigrationWriter(&writers.WriterOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create writer: %v", err)
|
||||
}
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteMigration(model, current); err != nil {
|
||||
t.Fatalf("WriteMigration failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if strings.Contains(output, "ALTER COLUMN email TYPE") {
|
||||
t.Fatalf("did not expect alter type for equivalent normalized types, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMigration_GinIndexOnTextUsesTrigramOperatorClass(t *testing.T) {
|
||||
current := models.InitDatabase("testdb")
|
||||
currentSchema := models.InitSchema("public")
|
||||
current.Schemas = append(current.Schemas, currentSchema)
|
||||
|
||||
model := models.InitDatabase("testdb")
|
||||
modelSchema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("articles", "public")
|
||||
titleCol := models.InitColumn("title", "articles", "public")
|
||||
titleCol.Type = "text"
|
||||
table.Columns["title"] = titleCol
|
||||
|
||||
index := &models.Index{
|
||||
Name: "idx_articles_title_gin",
|
||||
Type: "gin",
|
||||
Columns: []string{"title"},
|
||||
}
|
||||
table.Indexes[index.Name] = index
|
||||
|
||||
modelSchema.Tables = append(modelSchema.Tables, table)
|
||||
model.Schemas = append(model.Schemas, modelSchema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer, err := NewMigrationWriter(&writers.WriterOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create writer: %v", err)
|
||||
}
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteMigration(model, current); err != nil {
|
||||
t.Fatalf("WriteMigration failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "CREATE EXTENSION IF NOT EXISTS pg_trgm;") {
|
||||
t.Fatalf("expected trigram extension for text GIN migration index, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "USING gin (title gin_trgm_ops)") {
|
||||
t.Fatalf("expected GIN text index to include gin_trgm_ops, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMigration_GinIndexOnQuotedTextColumnUsesTrigramOperatorClass(t *testing.T) {
|
||||
current := models.InitDatabase("testdb")
|
||||
currentSchema := models.InitSchema("public")
|
||||
current.Schemas = append(current.Schemas, currentSchema)
|
||||
|
||||
model := models.InitDatabase("testdb")
|
||||
modelSchema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("agent_personas", "public")
|
||||
nameCol := models.InitColumn("name", "agent_personas", "public")
|
||||
nameCol.Type = "text"
|
||||
table.Columns["name"] = nameCol
|
||||
|
||||
index := &models.Index{
|
||||
Name: "idx_agent_personas_name_gin",
|
||||
Type: "gin",
|
||||
Columns: []string{`"name"`},
|
||||
}
|
||||
table.Indexes[index.Name] = index
|
||||
|
||||
modelSchema.Tables = append(modelSchema.Tables, table)
|
||||
model.Schemas = append(model.Schemas, modelSchema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer, err := NewMigrationWriter(&writers.WriterOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create writer: %v", err)
|
||||
}
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteMigration(model, current); err != nil {
|
||||
t.Fatalf("WriteMigration failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "USING gin (name gin_trgm_ops)") {
|
||||
t.Fatalf("expected quoted text column GIN index to include gin_trgm_ops, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMigration_GinIndexOnTextArrayDoesNotUseTrigramOperatorClass(t *testing.T) {
|
||||
current := models.InitDatabase("testdb")
|
||||
currentSchema := models.InitSchema("public")
|
||||
current.Schemas = append(current.Schemas, currentSchema)
|
||||
|
||||
model := models.InitDatabase("testdb")
|
||||
modelSchema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("plans", "public")
|
||||
tagsCol := models.InitColumn("tags", "plans", "public")
|
||||
tagsCol.Type = "text[]"
|
||||
table.Columns["tags"] = tagsCol
|
||||
|
||||
index := &models.Index{
|
||||
Name: "idx_plans_tags",
|
||||
Type: "gin",
|
||||
Columns: []string{"tags"},
|
||||
}
|
||||
table.Indexes[index.Name] = index
|
||||
|
||||
modelSchema.Tables = append(modelSchema.Tables, table)
|
||||
model.Schemas = append(model.Schemas, modelSchema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer, err := NewMigrationWriter(&writers.WriterOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create writer: %v", err)
|
||||
}
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteMigration(model, current); err != nil {
|
||||
t.Fatalf("WriteMigration failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "USING gin (tags array_ops)") {
|
||||
t.Fatalf("expected GIN array index with array_ops, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "gin_trgm_ops") {
|
||||
t.Fatalf("did not expect gin_trgm_ops for text[] migration index, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMigration_GinIndexOnJSONBUsesJSONBOperatorClass(t *testing.T) {
|
||||
current := models.InitDatabase("testdb")
|
||||
currentSchema := models.InitSchema("public")
|
||||
current.Schemas = append(current.Schemas, currentSchema)
|
||||
|
||||
model := models.InitDatabase("testdb")
|
||||
modelSchema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("learnings", "public")
|
||||
detailsCol := models.InitColumn("details", "learnings", "public")
|
||||
detailsCol.Type = "jsonb"
|
||||
table.Columns["details"] = detailsCol
|
||||
|
||||
index := &models.Index{
|
||||
Name: "idx_learnings_details",
|
||||
Type: "gin",
|
||||
Columns: []string{"details"},
|
||||
}
|
||||
table.Indexes[index.Name] = index
|
||||
|
||||
modelSchema.Tables = append(modelSchema.Tables, table)
|
||||
model.Schemas = append(model.Schemas, modelSchema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer, err := NewMigrationWriter(&writers.WriterOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create writer: %v", err)
|
||||
}
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteMigration(model, current); err != nil {
|
||||
t.Fatalf("WriteMigration failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "USING gin (details jsonb_ops)") {
|
||||
t.Fatalf("expected GIN jsonb index to include jsonb_ops, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "gin_trgm_ops") {
|
||||
t.Fatalf("did not expect gin_trgm_ops for jsonb migration index, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMigration_GinIndexOnJSONBIgnoresIncompatibleTrigramOperatorClass(t *testing.T) {
|
||||
current := models.InitDatabase("testdb")
|
||||
currentSchema := models.InitSchema("public")
|
||||
current.Schemas = append(current.Schemas, currentSchema)
|
||||
|
||||
model := models.InitDatabase("testdb")
|
||||
modelSchema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("learnings", "public")
|
||||
detailsCol := models.InitColumn("details", "learnings", "public")
|
||||
detailsCol.Type = "jsonb"
|
||||
table.Columns["details"] = detailsCol
|
||||
|
||||
index := &models.Index{
|
||||
Name: "idx_learnings_details",
|
||||
Type: "gin",
|
||||
Columns: []string{"details"},
|
||||
Comment: "gin_trgm_ops",
|
||||
}
|
||||
table.Indexes[index.Name] = index
|
||||
|
||||
modelSchema.Tables = append(modelSchema.Tables, table)
|
||||
model.Schemas = append(model.Schemas, modelSchema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer, err := NewMigrationWriter(&writers.WriterOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create writer: %v", err)
|
||||
}
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteMigration(model, current); err != nil {
|
||||
t.Fatalf("WriteMigration failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "USING gin (details jsonb_ops)") {
|
||||
t.Fatalf("expected incompatible trigram hint on jsonb to fall back to jsonb_ops, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMigration_WithAudit(t *testing.T) {
|
||||
// Current database (empty)
|
||||
current := models.InitDatabase("testdb")
|
||||
@@ -282,3 +686,46 @@ func TestWriteMigration_NumericConstraintNames(t *testing.T) {
|
||||
t.Error("Migration missing FOREIGN KEY")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMigrationWriter_NilOptions(t *testing.T) {
|
||||
writer, err := NewMigrationWriter(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMigrationWriter(nil) returned error: %v", err)
|
||||
}
|
||||
if writer == nil {
|
||||
t.Fatal("expected writer instance")
|
||||
}
|
||||
if writer.options == nil {
|
||||
t.Fatal("expected default writer options to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMigration_NilCurrentTreatsDatabaseAsEmpty(t *testing.T) {
|
||||
model := models.InitDatabase("testdb")
|
||||
modelSchema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("users", "public")
|
||||
idCol := models.InitColumn("id", "users", "public")
|
||||
idCol.Type = "integer"
|
||||
idCol.NotNull = true
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
modelSchema.Tables = append(modelSchema.Tables, table)
|
||||
model.Schemas = append(model.Schemas, modelSchema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer, err := NewMigrationWriter(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create writer: %v", err)
|
||||
}
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteMigration(model, nil); err != nil {
|
||||
t.Fatalf("WriteMigration with nil current failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "CREATE TABLE") {
|
||||
t.Fatalf("expected CREATE TABLE in migration output, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
)
|
||||
|
||||
// TemplateFunctions returns a map of custom template functions
|
||||
@@ -162,24 +164,12 @@ func quoteIdent(s string) string {
|
||||
|
||||
// Type conversion functions
|
||||
|
||||
// goTypeToSQL converts Go type to PostgreSQL type
|
||||
// goTypeToSQL converts Go type to PostgreSQL type using the shared pgsql type map.
|
||||
func goTypeToSQL(goType string) string {
|
||||
typeMap := map[string]string{
|
||||
"string": "text",
|
||||
"int": "integer",
|
||||
"int32": "integer",
|
||||
"int64": "bigint",
|
||||
"float32": "real",
|
||||
"float64": "double precision",
|
||||
"bool": "boolean",
|
||||
"time.Time": "timestamp",
|
||||
"[]byte": "bytea",
|
||||
}
|
||||
|
||||
if sqlType, ok := typeMap[goType]; ok {
|
||||
if sqlType, ok := pgsql.GoToPGSQLTypes[goType]; ok {
|
||||
return sqlType
|
||||
}
|
||||
return "text" // Default
|
||||
return "text"
|
||||
}
|
||||
|
||||
// sqlTypeToGo converts PostgreSQL type to Go type
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"text/template"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
//go:embed templates/*.tmpl
|
||||
@@ -94,6 +95,16 @@ type AlterColumnTypeData struct {
|
||||
TableName string
|
||||
ColumnName string
|
||||
NewType string
|
||||
UsingExpr string
|
||||
}
|
||||
|
||||
type AlterColumnTypeWithCheckData struct {
|
||||
SchemaName string
|
||||
TableName string
|
||||
ColumnName string
|
||||
NewType string
|
||||
EquivalentTypes string
|
||||
UsingExpr string
|
||||
}
|
||||
|
||||
// AlterColumnDefaultData contains data for alter column default template
|
||||
@@ -266,6 +277,7 @@ type CreatePrimaryKeyWithAutoGenCheckData struct {
|
||||
ConstraintName string
|
||||
AutoGenNames string // Comma-separated list of names like "'name1', 'name2'"
|
||||
Columns string
|
||||
ColumnNames string // Comma-separated list of quoted column names like "'id', 'tenant_id'"
|
||||
}
|
||||
|
||||
// Execute methods for each template
|
||||
@@ -300,6 +312,15 @@ func (te *TemplateExecutor) ExecuteAlterColumnType(data AlterColumnTypeData) (st
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func (te *TemplateExecutor) ExecuteAlterColumnTypeWithCheck(data AlterColumnTypeWithCheckData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "alter_column_type_with_check.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute alter_column_type_with_check template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// ExecuteAlterColumnDefault executes the alter column default template
|
||||
func (te *TemplateExecutor) ExecuteAlterColumnDefault(data AlterColumnDefaultData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
@@ -495,7 +516,11 @@ func BuildCreateTableData(schemaName string, table *models.Table) CreateTableDat
|
||||
NotNull: col.NotNull,
|
||||
}
|
||||
if col.Default != nil {
|
||||
colData.Default = fmt.Sprintf("%v", col.Default)
|
||||
if value, ok := col.Default.(string); ok {
|
||||
colData.Default = writers.QuoteDefaultValue(value, col.Type)
|
||||
} else {
|
||||
colData.Default = fmt.Sprintf("%v", col.Default)
|
||||
}
|
||||
}
|
||||
columns = append(columns, colData)
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
ALTER TABLE {{qual_table .SchemaName .TableName}}
|
||||
ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}};
|
||||
ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{end}};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
current_type text;
|
||||
BEGIN
|
||||
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||
INTO current_type
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class t ON t.oid = a.attrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = '{{.SchemaName}}'
|
||||
AND t.relname = '{{.TableName}}'
|
||||
AND a.attname = '{{.ColumnName}}'
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped;
|
||||
|
||||
IF current_type IS NOT NULL
|
||||
AND current_type <> ALL(ARRAY[{{.EquivalentTypes}}]) THEN
|
||||
ALTER TABLE {{qual_table .SchemaName .TableName}}
|
||||
ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{end}};
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -1,26 +1,42 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
auto_pk_name text;
|
||||
current_pk_name text;
|
||||
current_pk_matches boolean := false;
|
||||
BEGIN
|
||||
-- Drop auto-generated primary key if it exists
|
||||
SELECT constraint_name INTO auto_pk_name
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = '{{.SchemaName}}'
|
||||
AND table_name = '{{.TableName}}'
|
||||
AND constraint_type = 'PRIMARY KEY'
|
||||
AND constraint_name IN ({{.AutoGenNames}});
|
||||
SELECT tc.constraint_name,
|
||||
COALESCE(
|
||||
ARRAY(
|
||||
SELECT a.attname::text
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
JOIN unnest(c.conkey) WITH ORDINALITY AS cols(attnum, ord)
|
||||
ON TRUE
|
||||
JOIN pg_attribute a
|
||||
ON a.attrelid = t.oid
|
||||
AND a.attnum = cols.attnum
|
||||
WHERE c.contype = 'p'
|
||||
AND n.nspname = '{{.SchemaName}}'
|
||||
AND t.relname = '{{.TableName}}'
|
||||
ORDER BY cols.ord
|
||||
),
|
||||
ARRAY[]::text[]
|
||||
) = ARRAY[{{.ColumnNames}}]
|
||||
INTO current_pk_name, current_pk_matches
|
||||
FROM information_schema.table_constraints tc
|
||||
WHERE tc.table_schema = '{{.SchemaName}}'
|
||||
AND tc.table_name = '{{.TableName}}'
|
||||
AND tc.constraint_type = 'PRIMARY KEY';
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE {{qual_table .SchemaName .TableName}} DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
IF current_pk_name IS NOT NULL
|
||||
AND NOT current_pk_matches
|
||||
AND current_pk_name IN ({{.AutoGenNames}}) THEN
|
||||
EXECUTE 'ALTER TABLE {{qual_table .SchemaName .TableName}} DROP CONSTRAINT ' || quote_ident(current_pk_name) || ' CASCADE';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_schema = '{{.SchemaName}}'
|
||||
AND table_name = '{{.TableName}}'
|
||||
AND constraint_name = '{{.ConstraintName}}'
|
||||
) THEN
|
||||
-- Add the desired primary key only when no matching primary key already exists.
|
||||
IF current_pk_name IS NULL
|
||||
OR (NOT current_pk_matches AND current_pk_name IN ({{.AutoGenNames}})) THEN
|
||||
ALTER TABLE {{qual_table .SchemaName .TableName}} ADD CONSTRAINT {{quote_ident .ConstraintName}} PRIMARY KEY ({{.Columns}});
|
||||
END IF;
|
||||
END;
|
||||
|
||||
+431
-59
@@ -99,7 +99,11 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
// Write header comment
|
||||
fmt.Fprintf(w.writer, "-- PostgreSQL Database Schema\n")
|
||||
fmt.Fprintf(w.writer, "-- Database: %s\n", db.Name)
|
||||
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n\n")
|
||||
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n")
|
||||
if w.options.ContinueOnError {
|
||||
fmt.Fprintf(w.writer, "\\set ON_ERROR_STOP off\n")
|
||||
}
|
||||
fmt.Fprintf(w.writer, "\n")
|
||||
|
||||
// Process each schema in the database
|
||||
for _, schema := range db.Schemas {
|
||||
@@ -143,6 +147,10 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
statements = append(statements, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", schema.SQLName()))
|
||||
}
|
||||
|
||||
if schemaRequiresPGTrgm(schema) {
|
||||
statements = append(statements, `CREATE EXTENSION IF NOT EXISTS pg_trgm`)
|
||||
}
|
||||
|
||||
// Phase 2: Create sequences
|
||||
for _, table := range schema.Tables {
|
||||
pk := table.GetPrimaryKey()
|
||||
@@ -181,6 +189,12 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
}
|
||||
statements = append(statements, addColStmts...)
|
||||
|
||||
alterTypeStmts, err := w.GenerateAlterColumnTypeStatements(schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate alter column type statements: %w", err)
|
||||
}
|
||||
statements = append(statements, alterTypeStmts...)
|
||||
|
||||
// Phase 4: Primary keys
|
||||
for _, table := range schema.Tables {
|
||||
// First check for explicit PrimaryKeyConstraint
|
||||
@@ -228,6 +242,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
ConstraintName: pkName,
|
||||
AutoGenNames: formatStringList(autoGenPKNames),
|
||||
Columns: strings.Join(pkColumns, ", "),
|
||||
ColumnNames: formatStringList(pkColumns),
|
||||
}
|
||||
|
||||
stmt, err := w.executor.ExecuteCreatePrimaryKeyWithAutoGenCheck(data)
|
||||
@@ -260,14 +275,11 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
columnExprs := make([]string, 0, len(index.Columns))
|
||||
for _, colName := range index.Columns {
|
||||
colExpr := colName
|
||||
if col, ok := table.Columns[colName]; ok {
|
||||
// For GIN indexes on text columns, add operator class
|
||||
if strings.EqualFold(indexType, "gin") && isTextType(col.Type) {
|
||||
opClass := extractOperatorClass(index.Comment)
|
||||
if opClass == "" {
|
||||
opClass = "gin_trgm_ops"
|
||||
if col, ok := resolveIndexColumn(table, colName); ok {
|
||||
if strings.EqualFold(indexType, "gin") {
|
||||
if opClass := ginOperatorClassForColumn(col, index.Comment); opClass != "" {
|
||||
colExpr = fmt.Sprintf("%s %s", colName, opClass)
|
||||
}
|
||||
colExpr = fmt.Sprintf("%s %s", colName, opClass)
|
||||
}
|
||||
}
|
||||
columnExprs = append(columnExprs, colExpr)
|
||||
@@ -436,6 +448,33 @@ func (w *Writer) GenerateAddColumnStatements(schema *models.Schema) ([]string, e
|
||||
return statements, nil
|
||||
}
|
||||
|
||||
func (w *Writer) GenerateAlterColumnTypeStatements(schema *models.Schema) ([]string, error) {
|
||||
statements := []string{}
|
||||
|
||||
statements = append(statements, fmt.Sprintf("-- Alter column types for schema: %s", schema.Name))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
columns := getSortedColumns(table.Columns)
|
||||
for _, col := range columns {
|
||||
targetType := effectiveAlterColumnSQLType(col)
|
||||
stmt, err := w.executor.ExecuteAlterColumnTypeWithCheck(AlterColumnTypeWithCheckData{
|
||||
SchemaName: schema.Name,
|
||||
TableName: table.Name,
|
||||
ColumnName: col.Name,
|
||||
NewType: targetType,
|
||||
EquivalentTypes: equivalentTypeListSQL(targetType),
|
||||
UsingExpr: buildAlterColumnUsingExpression(col.Name, targetType),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate alter column type for %s.%s.%s: %w", schema.Name, table.Name, col.Name, err)
|
||||
}
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
|
||||
return statements, nil
|
||||
}
|
||||
|
||||
// GenerateAddColumnsForDatabase generates ALTER TABLE ADD COLUMN statements for the entire database
|
||||
func (w *Writer) GenerateAddColumnsForDatabase(db *models.Database) ([]string, error) {
|
||||
statements := []string{}
|
||||
@@ -488,31 +527,7 @@ func (w *Writer) generateCreateTableStatement(schema *models.Schema, table *mode
|
||||
func (w *Writer) generateColumnDefinition(col *models.Column) string {
|
||||
parts := []string{col.SQLName()}
|
||||
|
||||
// Type with length/precision - convert to valid PostgreSQL type
|
||||
baseType := pgsql.ConvertSQLType(col.Type)
|
||||
typeStr := baseType
|
||||
hasExplicitTypeModifier := pgsql.HasExplicitTypeModifier(baseType)
|
||||
|
||||
// Only add size specifiers for types that support them
|
||||
if !hasExplicitTypeModifier && col.Length > 0 && col.Precision == 0 {
|
||||
if pgsql.SupportsLength(baseType) {
|
||||
typeStr = fmt.Sprintf("%s(%d)", baseType, col.Length)
|
||||
} else if isTextTypeWithoutLength(baseType) {
|
||||
// Convert text with length to varchar
|
||||
typeStr = fmt.Sprintf("varchar(%d)", col.Length)
|
||||
}
|
||||
// For types that don't support length (integer, bigint, etc.), ignore the length
|
||||
} else if !hasExplicitTypeModifier && col.Precision > 0 {
|
||||
if pgsql.SupportsPrecision(baseType) {
|
||||
if col.Scale > 0 {
|
||||
typeStr = fmt.Sprintf("%s(%d,%d)", baseType, col.Precision, col.Scale)
|
||||
} else {
|
||||
typeStr = fmt.Sprintf("%s(%d)", baseType, col.Precision)
|
||||
}
|
||||
}
|
||||
// For types that don't support precision, ignore it
|
||||
}
|
||||
parts = append(parts, typeStr)
|
||||
parts = append(parts, effectiveColumnSQLType(col))
|
||||
|
||||
// NOT NULL
|
||||
if col.NotNull {
|
||||
@@ -523,15 +538,7 @@ func (w *Writer) generateColumnDefinition(col *models.Column) string {
|
||||
if col.Default != nil {
|
||||
switch v := col.Default.(type) {
|
||||
case string:
|
||||
// Strip backticks - DBML uses them for SQL expressions but PostgreSQL doesn't
|
||||
cleanDefault := stripBackticks(v)
|
||||
if strings.HasPrefix(cleanDefault, "nextval") || strings.HasPrefix(cleanDefault, "CURRENT_") || strings.Contains(cleanDefault, "()") {
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT %s", cleanDefault))
|
||||
} else if cleanDefault == "true" || cleanDefault == "false" {
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT %s", cleanDefault))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT '%s'", escapeQuote(cleanDefault)))
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT %s", writers.QuoteDefaultValue(stripBackticks(v), col.Type)))
|
||||
case bool:
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT %v", v))
|
||||
default:
|
||||
@@ -542,6 +549,64 @@ func (w *Writer) generateColumnDefinition(col *models.Column) string {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func effectiveColumnSQLType(col *models.Column) string {
|
||||
if col == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
baseType := pgsql.ConvertSQLType(col.Type)
|
||||
typeStr := baseType
|
||||
hasExplicitTypeModifier := pgsql.HasExplicitTypeModifier(baseType)
|
||||
|
||||
if !hasExplicitTypeModifier && col.Length > 0 && col.Precision == 0 {
|
||||
if pgsql.SupportsLength(baseType) {
|
||||
typeStr = fmt.Sprintf("%s(%d)", baseType, col.Length)
|
||||
} else if isTextTypeWithoutLength(baseType) {
|
||||
typeStr = fmt.Sprintf("varchar(%d)", col.Length)
|
||||
}
|
||||
} else if !hasExplicitTypeModifier && col.Precision > 0 {
|
||||
if pgsql.SupportsPrecision(baseType) {
|
||||
if col.Scale > 0 {
|
||||
typeStr = fmt.Sprintf("%s(%d,%d)", baseType, col.Precision, col.Scale)
|
||||
} else {
|
||||
typeStr = fmt.Sprintf("%s(%d)", baseType, col.Precision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return typeStr
|
||||
}
|
||||
|
||||
func effectiveAlterColumnSQLType(col *models.Column) string {
|
||||
typeStr := effectiveColumnSQLType(col)
|
||||
switch strings.ToLower(strings.TrimSpace(typeStr)) {
|
||||
case "smallserial":
|
||||
return "smallint"
|
||||
case "serial":
|
||||
return "integer"
|
||||
case "bigserial":
|
||||
return "bigint"
|
||||
default:
|
||||
return typeStr
|
||||
}
|
||||
}
|
||||
|
||||
func buildAlterColumnUsingExpression(columnName, targetType string) string {
|
||||
if strings.TrimSpace(columnName) == "" || strings.TrimSpace(targetType) == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s::%s", quoteIdent(columnName), targetType)
|
||||
}
|
||||
|
||||
func equivalentTypeListSQL(sqlType string) string {
|
||||
variants := pgsql.EquivalentSQLTypeVariants(sqlType)
|
||||
quoted := make([]string, 0, len(variants))
|
||||
for _, variant := range variants {
|
||||
quoted = append(quoted, fmt.Sprintf("'%s'", escapeQuote(variant)))
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
// WriteSchema writes a single schema and all its tables
|
||||
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
if w.writer == nil {
|
||||
@@ -553,6 +618,10 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := w.writeRequiredExtensions(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Phase 2: Create sequences (priority 80)
|
||||
if err := w.writeSequences(schema); err != nil {
|
||||
return err
|
||||
@@ -568,6 +637,10 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := w.writeAlterColumnTypes(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Phase 4: Create primary keys (priority 160)
|
||||
if err := w.writePrimaryKeys(schema); err != nil {
|
||||
return err
|
||||
@@ -668,6 +741,16 @@ func (w *Writer) writeCreateSchema(schema *models.Schema) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Writer) writeRequiredExtensions(schema *models.Schema) error {
|
||||
if !schemaRequiresPGTrgm(schema) {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintln(w.writer, "CREATE EXTENSION IF NOT EXISTS pg_trgm;")
|
||||
fmt.Fprintln(w.writer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeSequences generates CREATE SEQUENCE statements for identity columns
|
||||
func (w *Writer) writeSequences(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Sequences for schema: %s\n", schema.Name)
|
||||
@@ -761,6 +844,21 @@ func (w *Writer) writeAddColumns(schema *models.Schema) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Writer) writeAlterColumnTypes(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Alter column types for schema: %s\n", schema.Name)
|
||||
|
||||
statements, err := w.GenerateAlterColumnTypeStatements(schema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range statements[1:] {
|
||||
fmt.Fprint(w.writer, stmt)
|
||||
fmt.Fprint(w.writer, "\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writePrimaryKeys generates ALTER TABLE statements for primary keys
|
||||
func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Primary keys for schema: %s\n", schema.Name)
|
||||
@@ -814,6 +912,7 @@ func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
|
||||
ConstraintName: pkName,
|
||||
AutoGenNames: formatStringList(autoGenPKNames),
|
||||
Columns: strings.Join(columnNames, ", "),
|
||||
ColumnNames: formatStringList(columnNames),
|
||||
}
|
||||
|
||||
sql, err := w.executor.ExecuteCreatePrimaryKeyWithAutoGenCheck(data)
|
||||
@@ -861,15 +960,13 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
||||
// Build column list with operator class support for GIN indexes
|
||||
columnExprs := make([]string, 0, len(index.Columns))
|
||||
for _, colName := range index.Columns {
|
||||
if col, ok := table.Columns[colName]; ok {
|
||||
if col, ok := resolveIndexColumn(table, colName); ok {
|
||||
colExpr := col.SQLName()
|
||||
// For GIN indexes on text columns, add operator class
|
||||
if strings.EqualFold(index.Type, "gin") && isTextType(col.Type) {
|
||||
opClass := extractOperatorClass(index.Comment)
|
||||
if opClass == "" {
|
||||
opClass = "gin_trgm_ops"
|
||||
if strings.EqualFold(index.Type, "gin") {
|
||||
opClass := ginOperatorClassForColumn(col, index.Comment)
|
||||
if opClass != "" {
|
||||
colExpr = fmt.Sprintf("%s %s", col.SQLName(), opClass)
|
||||
}
|
||||
colExpr = fmt.Sprintf("%s %s", col.SQLName(), opClass)
|
||||
}
|
||||
columnExprs = append(columnExprs, colExpr)
|
||||
}
|
||||
@@ -1256,20 +1353,126 @@ func isIntegerType(colType string) bool {
|
||||
}
|
||||
|
||||
// isTextType checks if a column type is a text type (for GIN index operator class)
|
||||
func isTextType(colType string) bool {
|
||||
textTypes := []string{"text", "varchar", "character varying", "char", "character", "string"}
|
||||
lowerType := strings.ToLower(colType)
|
||||
for _, t := range textTypes {
|
||||
if strings.HasPrefix(lowerType, t) {
|
||||
return true
|
||||
// func isTextType(colType string) bool {
|
||||
// textTypes := []string{"text", "varchar", "character varying", "char", "character", "string"}
|
||||
// lowerType := strings.ToLower(colType)
|
||||
// if strings.HasSuffix(lowerType, "[]") {
|
||||
// return false
|
||||
// }
|
||||
// for _, t := range textTypes {
|
||||
// if strings.HasPrefix(lowerType, t) {
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
|
||||
// isTextTypeWithoutLength checks if type is text (which should convert to varchar when length is specified)
|
||||
func isTextTypeWithoutLength(colType string) bool {
|
||||
return strings.EqualFold(colType, "text")
|
||||
}
|
||||
|
||||
func ginOperatorClassForColumn(col *models.Column, comment string) string {
|
||||
if col == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
sqlType := effectiveColumnSQLType(col)
|
||||
baseType := pgsql.CanonicalizeBaseType(pgsql.ExtractBaseTypeLower(sqlType))
|
||||
isArray := pgsql.IsArrayType(sqlType)
|
||||
requested := extractOperatorClass(comment)
|
||||
|
||||
if requested != "" && ginOperatorClassCompatible(baseType, isArray, requested) {
|
||||
return requested
|
||||
}
|
||||
|
||||
if isArray {
|
||||
return "array_ops"
|
||||
}
|
||||
|
||||
switch {
|
||||
case isTextGinBaseType(baseType):
|
||||
return "gin_trgm_ops"
|
||||
case baseType == "jsonb":
|
||||
return "jsonb_ops"
|
||||
default:
|
||||
return requested
|
||||
}
|
||||
}
|
||||
|
||||
func ginOperatorClassCompatible(baseType string, isArray bool, opClass string) bool {
|
||||
switch opClass {
|
||||
case "gin_trgm_ops", "gin_bigm_ops":
|
||||
return !isArray && isTextGinBaseType(baseType)
|
||||
case "jsonb_ops", "jsonb_path_ops":
|
||||
return !isArray && baseType == "jsonb"
|
||||
case "array_ops":
|
||||
return isArray
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func isTextGinBaseType(baseType string) bool {
|
||||
switch baseType {
|
||||
case "text", "varchar", "character varying", "char", "character", "string", "citext", "bpchar":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func schemaRequiresPGTrgm(schema *models.Schema) bool {
|
||||
if schema == nil {
|
||||
return false
|
||||
}
|
||||
for _, table := range schema.Tables {
|
||||
if table == nil {
|
||||
continue
|
||||
}
|
||||
for _, index := range table.Indexes {
|
||||
if index == nil || !strings.EqualFold(index.Type, "gin") {
|
||||
continue
|
||||
}
|
||||
for _, colName := range index.Columns {
|
||||
col, ok := resolveIndexColumn(table, colName)
|
||||
if !ok || col == nil {
|
||||
continue
|
||||
}
|
||||
if ginOperatorClassForColumn(col, index.Comment) == "gin_trgm_ops" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isTextTypeWithoutLength checks if type is text (which should convert to varchar when length is specified)
|
||||
func isTextTypeWithoutLength(colType string) bool {
|
||||
return strings.EqualFold(colType, "text")
|
||||
func resolveIndexColumn(table *models.Table, colName string) (*models.Column, bool) {
|
||||
if table == nil {
|
||||
return nil, false
|
||||
}
|
||||
if col, ok := table.Columns[colName]; ok && col != nil {
|
||||
return col, true
|
||||
}
|
||||
|
||||
normalized := strings.ToLower(strings.Trim(colName, `"`))
|
||||
for key, col := range table.Columns {
|
||||
if col == nil {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(strings.Trim(key, `"`)) == normalized {
|
||||
return col, true
|
||||
}
|
||||
if strings.ToLower(strings.Trim(col.Name, `"`)) == normalized {
|
||||
return col, true
|
||||
}
|
||||
if strings.ToLower(strings.Trim(col.SQLName(), `"`)) == normalized {
|
||||
return col, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// formatStringList formats a list of strings as a SQL-safe comma-separated quoted list
|
||||
@@ -1390,7 +1593,12 @@ func (w *Writer) executeDatabaseSQL(db *models.Database, connString string) erro
|
||||
}
|
||||
|
||||
stmtType := detectStatementType(stmtTrimmed)
|
||||
fmt.Fprintf(os.Stderr, "Executing statement %d/%d [%s]...\n", i+1, len(statements), stmtType)
|
||||
stmtCtx := extractStatementContext(stmtTrimmed)
|
||||
if stmtCtx != "" {
|
||||
fmt.Fprintf(os.Stderr, "Executing statement %d/%d [%s] %s...\n", i+1, len(statements), stmtType, stmtCtx)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Executing statement %d/%d [%s]...\n", i+1, len(statements), stmtType)
|
||||
}
|
||||
|
||||
_, execErr := conn.Exec(ctx, stmt)
|
||||
if execErr != nil {
|
||||
@@ -1525,6 +1733,170 @@ func getCurrentTimestamp() string {
|
||||
return time.Now().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// extractStatementContext returns a human-readable schema/table/column context string for a SQL statement.
|
||||
func extractStatementContext(stmt string) string {
|
||||
upper := strings.ToUpper(stmt)
|
||||
|
||||
// DO $$ blocks: extract identifiers from information_schema WHERE clauses
|
||||
if strings.HasPrefix(upper, "DO $$") || strings.HasPrefix(upper, "DO $") {
|
||||
schema := extractSQLStringValue(stmt, "table_schema")
|
||||
table := extractSQLStringValue(stmt, "table_name")
|
||||
column := extractSQLStringValue(stmt, "column_name")
|
||||
constraint := extractSQLStringValue(stmt, "constraint_name")
|
||||
return buildStmtContext(schema, table, column, constraint)
|
||||
}
|
||||
|
||||
// ALTER TABLE [schema.]table ...
|
||||
if strings.HasPrefix(upper, "ALTER TABLE") {
|
||||
schema, table := parseQualifiedIdent(strings.TrimSpace(stmt[11:]))
|
||||
if strings.Contains(upper, "ADD COLUMN") {
|
||||
col := firstIdentAfterKeyword(stmt, upper, "ADD COLUMN")
|
||||
return buildStmtContext(schema, table, col, "")
|
||||
}
|
||||
if strings.Contains(upper, "ALTER COLUMN") {
|
||||
col := firstIdentAfterKeyword(stmt, upper, "ALTER COLUMN")
|
||||
return buildStmtContext(schema, table, col, "")
|
||||
}
|
||||
if strings.Contains(upper, "ADD CONSTRAINT") {
|
||||
con := firstIdentAfterKeyword(stmt, upper, "ADD CONSTRAINT")
|
||||
return buildStmtContext(schema, table, "", con)
|
||||
}
|
||||
if strings.Contains(upper, "DROP CONSTRAINT") {
|
||||
con := firstIdentAfterKeyword(stmt, upper, "DROP CONSTRAINT")
|
||||
return buildStmtContext(schema, table, "", con)
|
||||
}
|
||||
return buildStmtContext(schema, table, "", "")
|
||||
}
|
||||
|
||||
// CREATE TABLE [IF NOT EXISTS] [schema.]table
|
||||
if strings.HasPrefix(upper, "CREATE TABLE") {
|
||||
rest := strings.TrimSpace(stmt[12:])
|
||||
if strings.HasPrefix(strings.ToUpper(rest), "IF NOT EXISTS") {
|
||||
rest = strings.TrimSpace(rest[13:])
|
||||
}
|
||||
schema, table := parseQualifiedIdent(rest)
|
||||
return buildStmtContext(schema, table, "", "")
|
||||
}
|
||||
|
||||
// CREATE SCHEMA name
|
||||
if strings.HasPrefix(upper, "CREATE SCHEMA") {
|
||||
name := firstBareIdent(strings.TrimSpace(stmt[13:]))
|
||||
return name
|
||||
}
|
||||
|
||||
// CREATE [UNIQUE] INDEX ... ON [schema.]table
|
||||
if strings.HasPrefix(upper, "CREATE INDEX") || strings.HasPrefix(upper, "CREATE UNIQUE INDEX") {
|
||||
onIdx := strings.Index(upper, " ON ")
|
||||
if onIdx != -1 {
|
||||
schema, table := parseQualifiedIdent(strings.TrimSpace(stmt[onIdx+4:]))
|
||||
return buildStmtContext(schema, table, "", "")
|
||||
}
|
||||
}
|
||||
|
||||
// COMMENT ON TABLE [schema.]table
|
||||
if strings.HasPrefix(upper, "COMMENT ON TABLE") {
|
||||
schema, table := parseQualifiedIdent(strings.TrimSpace(stmt[16:]))
|
||||
return buildStmtContext(schema, table, "", "")
|
||||
}
|
||||
|
||||
// COMMENT ON COLUMN [schema.]table.col
|
||||
if strings.HasPrefix(upper, "COMMENT ON COLUMN") {
|
||||
return firstBareIdent(strings.TrimSpace(stmt[17:]))
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractSQLStringValue extracts 'value' from patterns like: key = 'value' (case-insensitive key match).
|
||||
func extractSQLStringValue(stmt, key string) string {
|
||||
lower := strings.ToLower(stmt)
|
||||
idx := strings.Index(lower, strings.ToLower(key))
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimSpace(stmt[idx+len(key):])
|
||||
eqIdx := strings.IndexByte(rest, '=')
|
||||
if eqIdx == -1 || eqIdx > 5 {
|
||||
return ""
|
||||
}
|
||||
rest = strings.TrimSpace(rest[eqIdx+1:])
|
||||
if len(rest) == 0 || rest[0] != '\'' {
|
||||
return ""
|
||||
}
|
||||
rest = rest[1:]
|
||||
end := strings.IndexByte(rest, '\'')
|
||||
if end == -1 {
|
||||
return ""
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
|
||||
// parseQualifiedIdent extracts (schema, name) from the start of s, handling optional "schema"."table" quoting.
|
||||
func parseQualifiedIdent(s string) (schema, name string) {
|
||||
token := firstBareIdent(s)
|
||||
parts := strings.SplitN(token, ".", 2)
|
||||
if len(parts) == 2 {
|
||||
return stripQuotes(parts[0]), stripQuotes(parts[1])
|
||||
}
|
||||
return "", stripQuotes(token)
|
||||
}
|
||||
|
||||
// firstBareIdent returns the first whitespace/delimiter-terminated token, stripping outer quotes.
|
||||
func firstBareIdent(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
end := strings.IndexAny(s, " \t\n(,;")
|
||||
if end == -1 {
|
||||
return s
|
||||
}
|
||||
return s[:end]
|
||||
}
|
||||
|
||||
// firstIdentAfterKeyword returns the first identifier token after keyword (matched case-insensitively via upperStmt).
|
||||
func firstIdentAfterKeyword(stmt, upperStmt, keyword string) string {
|
||||
idx := strings.Index(upperStmt, keyword)
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
return stripQuotes(firstBareIdent(strings.TrimSpace(stmt[idx+len(keyword):])))
|
||||
}
|
||||
|
||||
// stripQuotes removes surrounding double-quotes from an identifier.
|
||||
func stripQuotes(s string) string {
|
||||
return strings.Trim(s, "\"")
|
||||
}
|
||||
|
||||
// buildStmtContext assembles a display string from available identifiers.
|
||||
func buildStmtContext(schema, table, column, constraint string) string {
|
||||
var b strings.Builder
|
||||
if schema != "" && table != "" {
|
||||
b.WriteString(schema)
|
||||
b.WriteByte('.')
|
||||
b.WriteString(table)
|
||||
} else if table != "" {
|
||||
b.WriteString(table)
|
||||
}
|
||||
if column != "" {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteByte('(')
|
||||
b.WriteString(column)
|
||||
b.WriteByte(')')
|
||||
}
|
||||
if constraint != "" {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteByte('[')
|
||||
b.WriteString(constraint)
|
||||
b.WriteByte(']')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// detectStatementType detects the type of SQL statement for logging
|
||||
func detectStatementType(stmt string) string {
|
||||
upperStmt := strings.ToUpper(stmt)
|
||||
|
||||
@@ -87,6 +87,117 @@ func TestWriteDatabase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDatabase_GinIndexOnTextArrayDoesNotUseTrigramOperatorClass(t *testing.T) {
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("plans", "public")
|
||||
|
||||
tagsCol := models.InitColumn("tags", "plans", "public")
|
||||
tagsCol.Type = "text[]"
|
||||
table.Columns["tags"] = tagsCol
|
||||
|
||||
index := &models.Index{
|
||||
Name: "idx_plans_tags",
|
||||
Type: "gin",
|
||||
Columns: []string{"tags"},
|
||||
}
|
||||
table.Indexes[index.Name] = index
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteDatabase(db); err != nil {
|
||||
t.Fatalf("WriteDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, `USING gin (tags array_ops)`) {
|
||||
t.Fatalf("expected GIN index on array column with array_ops, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "gin_trgm_ops") {
|
||||
t.Fatalf("did not expect gin_trgm_ops for text[] column, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDatabase_GinIndexOnQuotedTextColumnUsesTrigramOperatorClass(t *testing.T) {
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("agent_personas", "public")
|
||||
|
||||
nameCol := models.InitColumn("name", "agent_personas", "public")
|
||||
nameCol.Type = "text"
|
||||
table.Columns["name"] = nameCol
|
||||
|
||||
index := &models.Index{
|
||||
Name: "idx_agent_personas_name_gin",
|
||||
Type: "gin",
|
||||
Columns: []string{`"name"`},
|
||||
}
|
||||
table.Indexes[index.Name] = index
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteDatabase(db); err != nil {
|
||||
t.Fatalf("WriteDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, `CREATE EXTENSION IF NOT EXISTS pg_trgm`) {
|
||||
t.Fatalf("expected trigram extension for text GIN index, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, `USING gin (name gin_trgm_ops)`) {
|
||||
t.Fatalf("expected quoted text GIN index to include gin_trgm_ops, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDatabase_GinIndexOnJSONBUsesJSONBOperatorClass(t *testing.T) {
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("learnings", "public")
|
||||
|
||||
detailsCol := models.InitColumn("details", "learnings", "public")
|
||||
detailsCol.Type = "jsonb"
|
||||
table.Columns["details"] = detailsCol
|
||||
|
||||
index := &models.Index{
|
||||
Name: "idx_learnings_details",
|
||||
Type: "gin",
|
||||
Columns: []string{"details"},
|
||||
}
|
||||
table.Indexes[index.Name] = index
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteDatabase(db); err != nil {
|
||||
t.Fatalf("WriteDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, `USING gin (details jsonb_ops)`) {
|
||||
t.Fatalf("expected GIN jsonb index to include jsonb_ops, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "gin_trgm_ops") {
|
||||
t.Fatalf("did not expect gin_trgm_ops for jsonb column, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteForeignKeys(t *testing.T) {
|
||||
// Create a test database with two related tables
|
||||
db := models.InitDatabase("testdb")
|
||||
@@ -636,9 +747,14 @@ func TestPrimaryKeyExistenceCheck(t *testing.T) {
|
||||
t.Errorf("Output missing logic to drop auto-generated primary key\nFull output:\n%s", output)
|
||||
}
|
||||
|
||||
// Verify it checks for our specific named constraint before adding it
|
||||
if !strings.Contains(output, "constraint_name = 'pk_public_products'") {
|
||||
t.Errorf("Output missing check for our named primary key constraint\nFull output:\n%s", output)
|
||||
// Verify it compares the current primary key columns before dropping/recreating
|
||||
if !strings.Contains(output, "current_pk_matches") || !strings.Contains(output, "ARRAY['id']") {
|
||||
t.Errorf("Output missing safe primary key comparison logic\nFull output:\n%s", output)
|
||||
}
|
||||
|
||||
// Verify it only adds the desired key when no PK exists or an auto-generated mismatch was dropped
|
||||
if !strings.Contains(output, "current_pk_name IS NULL") || !strings.Contains(output, "current_pk_name IN ('products_pkey', 'public_products_pkey')") {
|
||||
t.Errorf("Output missing guarded primary key creation logic\nFull output:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,6 +845,43 @@ func TestColumnSizeSpecifiers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDatabase_PrimaryKeyTemplateDoesNotDropMatchingAutoPrimaryKey(t *testing.T) {
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("public")
|
||||
table := models.InitTable("learnings", "public")
|
||||
|
||||
idCol := models.InitColumn("id", "learnings", "public")
|
||||
idCol.Type = "bigint"
|
||||
idCol.IsPrimaryKey = true
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
parentCol := models.InitColumn("duplicate_of_learning_id", "learnings", "public")
|
||||
parentCol.Type = "bigint"
|
||||
table.Columns["duplicate_of_learning_id"] = parentCol
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteDatabase(db); err != nil {
|
||||
t.Fatalf("WriteDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "current_pk_matches") {
|
||||
t.Fatalf("expected generated SQL to compare current PK columns, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ARRAY['id']") {
|
||||
t.Fatalf("expected generated SQL to compare against desired PK columns, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "NOT current_pk_matches") {
|
||||
t.Fatalf("expected generated SQL to avoid dropping matching PKs, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateColumnDefinition_PreservesExplicitTypeModifiers(t *testing.T) {
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
|
||||
@@ -905,3 +1058,82 @@ func TestWriteAddColumnStatements(t *testing.T) {
|
||||
t.Errorf("Output missing DO block\nFull output:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSchema_EmitsGuardedAlterColumnTypeStatements(t *testing.T) {
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("agent_skills", "public")
|
||||
|
||||
nameCol := models.InitColumn("name", "agent_skills", "public")
|
||||
nameCol.Type = "character varying"
|
||||
nameCol.Length = 255
|
||||
table.Columns["name"] = nameCol
|
||||
|
||||
tagsCol := models.InitColumn("tags", "agent_skills", "public")
|
||||
tagsCol.Type = "text[]"
|
||||
table.Columns["tags"] = tagsCol
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteDatabase(db); err != nil {
|
||||
t.Fatalf("WriteDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "-- Alter column types for schema: public") {
|
||||
t.Fatalf("expected alter column type section, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "pg_catalog.format_type") {
|
||||
t.Fatalf("expected guarded live-type check, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ALTER COLUMN name TYPE character varying(255)") {
|
||||
t.Fatalf("expected guarded alter for character varying(255), got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ARRAY['varchar(255)', 'character varying(255)']") {
|
||||
t.Fatalf("expected equivalent type spellings for varchar guard, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ALTER COLUMN tags TYPE text[]") {
|
||||
t.Fatalf("expected guarded alter for array type, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, `ALTER COLUMN tags TYPE text[] USING tags::text[];`) {
|
||||
t.Fatalf("expected guarded alter for array type to include USING cast, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSchema_UsesStorageTypeForSerialAlterStatements(t *testing.T) {
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("learnings", "public")
|
||||
idCol := models.InitColumn("id", "learnings", "public")
|
||||
idCol.Type = "bigserial"
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
writer.writer = &buf
|
||||
|
||||
if err := writer.WriteDatabase(db); err != nil {
|
||||
t.Fatalf("WriteDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "ALTER COLUMN id TYPE bigint") {
|
||||
t.Fatalf("expected serial alter to use bigint storage type, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "ALTER COLUMN id TYPE bigserial;") {
|
||||
t.Fatalf("did not expect invalid bigserial alter statement, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, `ALTER COLUMN id TYPE bigint USING id::bigint;`) {
|
||||
t.Fatalf("expected serial alter to include USING cast, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func (w *Writer) databaseToPrisma(db *models.Database) string {
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Write generator block
|
||||
sb.WriteString(w.generateGenerator())
|
||||
sb.WriteString(w.generateGenerator(db))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Process all schemas (typically just one in Prisma)
|
||||
@@ -114,13 +114,28 @@ func (w *Writer) generateDatasource(db *models.Database) string {
|
||||
}
|
||||
|
||||
// generateGenerator generates the generator block
|
||||
func (w *Writer) generateGenerator() string {
|
||||
func (w *Writer) generateGenerator(db *models.Database) string {
|
||||
if w.usePrisma7Generator(db) {
|
||||
return `generator client {
|
||||
provider = "prisma-client"
|
||||
output = "./generated"
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
return `generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
func (w *Writer) usePrisma7Generator(db *models.Database) bool {
|
||||
if w.options != nil && w.options.Prisma7 {
|
||||
return true
|
||||
}
|
||||
return db != nil && db.SourceFormat == "prisma7"
|
||||
}
|
||||
|
||||
// enumToPrisma converts an Enum to Prisma enum block
|
||||
func (w *Writer) enumToPrisma(enum *models.Enum) string {
|
||||
var sb strings.Builder
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package prisma
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
func TestGenerateGenerator_DefaultsToPrismaClientJS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
db := models.InitDatabase("testdb")
|
||||
|
||||
got := writer.generateGenerator(db)
|
||||
if !strings.Contains(got, `provider = "prisma-client-js"`) {
|
||||
t.Fatalf("expected prisma-client-js generator, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, `output = "./generated"`) {
|
||||
t.Fatalf("did not expect prisma7 output path in default generator:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateGenerator_Prisma7FlagUsesPrismaClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := NewWriter(&writers.WriterOptions{Prisma7: true})
|
||||
db := models.InitDatabase("testdb")
|
||||
|
||||
got := writer.generateGenerator(db)
|
||||
if !strings.Contains(got, `provider = "prisma-client"`) {
|
||||
t.Fatalf("expected prisma-client generator, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `output = "./generated"`) {
|
||||
t.Fatalf("expected prisma7 output path, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateGenerator_Prisma7SourceFormatUsesPrismaClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
db := models.InitDatabase("testdb")
|
||||
db.SourceFormat = "prisma7"
|
||||
|
||||
got := writer.generateGenerator(db)
|
||||
if !strings.Contains(got, `provider = "prisma-client"`) {
|
||||
t.Fatalf("expected prisma-client generator from source format, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package sqlite
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
sqlitepkg "git.warky.dev/wdevs/relspecgo/pkg/sqlite"
|
||||
)
|
||||
|
||||
// SQLite type affinities
|
||||
// SQLite type affinity constants
|
||||
const (
|
||||
TypeText = "TEXT"
|
||||
TypeInteger = "INTEGER"
|
||||
@@ -13,72 +15,27 @@ const (
|
||||
TypeBlob = "BLOB"
|
||||
)
|
||||
|
||||
// MapPostgreSQLType maps PostgreSQL data types to SQLite type affinities
|
||||
// MapTypeToSQLite maps any SQL or Go canonical type to a SQLite type affinity.
|
||||
// Handles input from any reader (PostgreSQL, MSSQL, SQLite, Go canonical).
|
||||
func MapTypeToSQLite(colType string) string {
|
||||
return sqlitepkg.ConvertCanonicalToSQLite(colType)
|
||||
}
|
||||
|
||||
// MapPostgreSQLType is an alias for MapTypeToSQLite kept for compatibility.
|
||||
//
|
||||
// Deprecated: use MapTypeToSQLite.
|
||||
func MapPostgreSQLType(pgType string) string {
|
||||
// Normalize the type
|
||||
normalized := strings.ToLower(strings.TrimSpace(pgType))
|
||||
|
||||
// Remove array notation if present
|
||||
normalized = strings.TrimSuffix(normalized, "[]")
|
||||
|
||||
// Remove precision/scale if present
|
||||
if idx := strings.Index(normalized, "("); idx != -1 {
|
||||
normalized = normalized[:idx]
|
||||
}
|
||||
|
||||
// Map to SQLite type affinity
|
||||
switch normalized {
|
||||
// TEXT affinity
|
||||
case "varchar", "character varying", "text", "char", "character",
|
||||
"citext", "uuid", "timestamp", "timestamptz", "timestamp with time zone",
|
||||
"timestamp without time zone", "date", "time", "timetz", "time with time zone",
|
||||
"time without time zone", "json", "jsonb", "xml", "inet", "cidr", "macaddr":
|
||||
return TypeText
|
||||
|
||||
// INTEGER affinity
|
||||
case "int", "int2", "int4", "int8", "integer", "smallint", "bigint",
|
||||
"serial", "smallserial", "bigserial", "boolean", "bool":
|
||||
return TypeInteger
|
||||
|
||||
// REAL affinity
|
||||
case "real", "float", "float4", "float8", "double precision":
|
||||
return TypeReal
|
||||
|
||||
// NUMERIC affinity
|
||||
case "numeric", "decimal", "money":
|
||||
return TypeNumeric
|
||||
|
||||
// BLOB affinity
|
||||
case "bytea", "blob":
|
||||
return TypeBlob
|
||||
|
||||
default:
|
||||
// Default to TEXT for unknown types
|
||||
return TypeText
|
||||
}
|
||||
return MapTypeToSQLite(pgType)
|
||||
}
|
||||
|
||||
// IsIntegerType checks if a column type should be treated as integer
|
||||
// IsIntegerType reports whether a column type maps to SQLite INTEGER affinity.
|
||||
func IsIntegerType(colType string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(colType))
|
||||
normalized = strings.TrimSuffix(normalized, "[]")
|
||||
if idx := strings.Index(normalized, "("); idx != -1 {
|
||||
normalized = normalized[:idx]
|
||||
}
|
||||
|
||||
switch normalized {
|
||||
case "int", "int2", "int4", "int8", "integer", "smallint", "bigint",
|
||||
"serial", "smallserial", "bigserial":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return MapTypeToSQLite(colType) == TypeInteger
|
||||
}
|
||||
|
||||
// MapBooleanValue converts PostgreSQL boolean literals to SQLite (0/1)
|
||||
// MapBooleanValue converts common boolean literals to SQLite integers (1/0).
|
||||
func MapBooleanValue(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "true", "t", "yes", "y", "1":
|
||||
return "1"
|
||||
case "false", "f", "no", "n", "0":
|
||||
|
||||
@@ -60,7 +60,7 @@ func Has(m interface{}, key interface{}) bool {
|
||||
v := reflect.ValueOf(m)
|
||||
|
||||
// Dereference pointers
|
||||
for v.Kind() == reflect.Ptr {
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return false
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func Merge(maps ...interface{}) map[interface{}]interface{} {
|
||||
v := reflect.ValueOf(m)
|
||||
|
||||
// Dereference pointers
|
||||
for v.Kind() == reflect.Ptr {
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
continue
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func Pick(m interface{}, keys ...interface{}) map[interface{}]interface{} {
|
||||
v := reflect.ValueOf(m)
|
||||
|
||||
// Dereference pointers
|
||||
for v.Kind() == reflect.Ptr {
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return result
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func Omit(m interface{}, keys ...interface{}) map[interface{}]interface{} {
|
||||
v := reflect.ValueOf(m)
|
||||
|
||||
// Dereference pointers
|
||||
for v.Kind() == reflect.Ptr {
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return result
|
||||
}
|
||||
@@ -237,7 +237,7 @@ func Pluck(slice interface{}, field string) []interface{} {
|
||||
v := reflect.ValueOf(slice)
|
||||
|
||||
// Dereference pointers
|
||||
for v.Kind() == reflect.Ptr {
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return []interface{}{}
|
||||
}
|
||||
@@ -253,13 +253,18 @@ func Pluck(slice interface{}, field string) []interface{} {
|
||||
item := v.Index(i)
|
||||
|
||||
// Dereference item pointers
|
||||
for item.Kind() == reflect.Ptr {
|
||||
nilItem := false
|
||||
for item.Kind() == reflect.Pointer {
|
||||
if item.IsNil() {
|
||||
result = append(result, nil)
|
||||
continue
|
||||
nilItem = true
|
||||
break
|
||||
}
|
||||
item = item.Elem()
|
||||
}
|
||||
if nilItem {
|
||||
continue
|
||||
}
|
||||
|
||||
switch item.Kind() {
|
||||
case reflect.Struct:
|
||||
|
||||
+74
-3
@@ -20,6 +20,23 @@ type Writer interface {
|
||||
WriteTable(table *models.Table) error
|
||||
}
|
||||
|
||||
// NullableType constants control which Go package is used for nullable column types
|
||||
// in code-generation writers (Bun, GORM).
|
||||
const (
|
||||
// NullableTypeSqlTypes uses git.warky.dev/wdevs/relspecgo/pkg/sqltypes
|
||||
// (SqlString, SqlInt32, SqlVector, SqlStringArray, …).
|
||||
NullableTypeSqlTypes = "sqltypes"
|
||||
|
||||
// NullableTypeStdlib uses the standard library database/sql nullable types
|
||||
// (sql.NullString, sql.NullInt32, …) and plain Go slices for arrays.
|
||||
NullableTypeStdlib = "stdlib"
|
||||
|
||||
// NullableTypeBaselib uses plain Go pointer types for nullable columns
|
||||
// (*string, *int32, *time.Time, …) and plain Go slices for arrays.
|
||||
// No external imports are required beyond the standard library. This is the default.
|
||||
NullableTypeBaselib = "baselib"
|
||||
)
|
||||
|
||||
// WriterOptions contains common options for writers
|
||||
type WriterOptions struct {
|
||||
// OutputPath is the path where the output should be written
|
||||
@@ -33,6 +50,20 @@ type WriterOptions struct {
|
||||
// Useful for databases like SQLite that do not support schemas.
|
||||
FlattenSchema bool
|
||||
|
||||
// NullableTypes selects the Go type package used for nullable columns in
|
||||
// code-generation writers (bun, gorm). Accepted values:
|
||||
// "sqltypes" — git.warky.dev/wdevs/relspecgo/pkg/sqltypes
|
||||
// "stdlib" — database/sql (sql.NullString, sql.NullInt32, …)
|
||||
// "baselib" (default) — plain Go pointer types (*string, *int32, …)
|
||||
NullableTypes string
|
||||
|
||||
// Prisma7 enables Prisma 7-specific output for Prisma writers.
|
||||
Prisma7 bool
|
||||
|
||||
// ContinueOnError instructs SQL writers to prepend `\set ON_ERROR_STOP off`
|
||||
// to their output so that psql continues past errors instead of stopping.
|
||||
ContinueOnError bool
|
||||
|
||||
// Additional options can be added here as needed
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
@@ -92,8 +123,12 @@ func SanitizeFilename(name string) string {
|
||||
// Examples (bigint): "0" → "0"
|
||||
// Examples (timestamp): "now()" → "now()" (function call – never quoted)
|
||||
func QuoteDefaultValue(value, sqlType string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Function calls are never quoted regardless of column type.
|
||||
if strings.Contains(value, "(") || strings.Contains(value, ")") {
|
||||
if strings.Contains(value, "(") || strings.Contains(value, ")") ||
|
||||
strings.Contains(value, "::") ||
|
||||
strings.HasPrefix(strings.ToUpper(value), "ARRAY[") {
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -103,6 +138,16 @@ func QuoteDefaultValue(value, sqlType string) string {
|
||||
baseType = baseType[:idx]
|
||||
}
|
||||
|
||||
if isArraySQLType(baseType) {
|
||||
if arrayLiteral, ok := normalizeArrayDefaultLiteral(value); ok {
|
||||
return quoteSQLLiteral(arrayLiteral)
|
||||
}
|
||||
}
|
||||
|
||||
if isQuotedSQLLiteral(value) {
|
||||
return value
|
||||
}
|
||||
|
||||
// Types whose default values must NOT be quoted.
|
||||
unquotedTypes := map[string]bool{
|
||||
// Integer types
|
||||
@@ -136,7 +181,32 @@ func QuoteDefaultValue(value, sqlType string) string {
|
||||
|
||||
// Everything else (text, varchar, char, uuid, date, time, timestamp, json, …)
|
||||
// is treated as a quoted literal.
|
||||
return "'" + value + "'"
|
||||
return quoteSQLLiteral(value)
|
||||
}
|
||||
|
||||
func isArraySQLType(sqlType string) bool {
|
||||
return strings.HasSuffix(sqlType, "[]")
|
||||
}
|
||||
|
||||
func normalizeArrayDefaultLiteral(value string) (string, bool) {
|
||||
switch {
|
||||
case strings.HasPrefix(value, "''{") && strings.HasSuffix(value, "}''"):
|
||||
return value[2 : len(value)-2], true
|
||||
case strings.HasPrefix(value, "'{") && strings.HasSuffix(value, "}'"):
|
||||
return value[1 : len(value)-1], true
|
||||
case strings.HasPrefix(value, "{") && strings.HasSuffix(value, "}"):
|
||||
return value, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func isQuotedSQLLiteral(value string) bool {
|
||||
return len(value) >= 2 && strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")
|
||||
}
|
||||
|
||||
func quoteSQLLiteral(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||
}
|
||||
|
||||
// SanitizeStructTagValue sanitizes a value to be safely used inside Go struct tags.
|
||||
@@ -147,7 +217,8 @@ func QuoteDefaultValue(value, sqlType string) string {
|
||||
// - Returns a clean identifier safe for use in struct tags and field names
|
||||
func SanitizeStructTagValue(value string) string {
|
||||
// Remove DBML/DCTX style comments in brackets (e.g., [note: 'description'])
|
||||
commentRegex := regexp.MustCompile(`\s*\[.*?\]\s*`)
|
||||
// Require at least one character inside brackets to avoid stripping PostgreSQL array suffix "[]"
|
||||
commentRegex := regexp.MustCompile(`\s*\[[^\]]+\]\s*`)
|
||||
value = commentRegex.ReplaceAllString(value, "")
|
||||
|
||||
// Trim whitespace
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package writers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestQuoteDefaultValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
sqlType string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "text default is quoted",
|
||||
value: "active",
|
||||
sqlType: "text",
|
||||
want: "'active'",
|
||||
},
|
||||
{
|
||||
name: "array default from bare literal is quoted once",
|
||||
value: "{}",
|
||||
sqlType: "text[]",
|
||||
want: "'{}'",
|
||||
},
|
||||
{
|
||||
name: "array default from quoted literal is preserved",
|
||||
value: "'{}'",
|
||||
sqlType: "text[]",
|
||||
want: "'{}'",
|
||||
},
|
||||
{
|
||||
name: "array default from double quoted literal is normalized",
|
||||
value: "''{}''",
|
||||
sqlType: "text[]",
|
||||
want: "'{}'",
|
||||
},
|
||||
{
|
||||
name: "function default is left alone",
|
||||
value: "now()",
|
||||
sqlType: "timestamptz",
|
||||
want: "now()",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := QuoteDefaultValue(tt.value, tt.sqlType)
|
||||
if got != tt.want {
|
||||
t.Fatalf("QuoteDefaultValue(%q, %q) = %q, want %q", tt.value, tt.sqlType, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
version: 1.0.{build}
|
||||
clone_folder: c:\gopath\src\github.com\gdamore\tcell
|
||||
environment:
|
||||
GOPATH: c:\gopath
|
||||
build_script:
|
||||
- go version
|
||||
- go env
|
||||
- SET PATH=%LOCALAPPDATA%\atom\bin;%GOPATH%\bin;%PATH%
|
||||
- go get -t ./...
|
||||
- go build
|
||||
- go install ./...
|
||||
test_script:
|
||||
- go test ./...
|
||||
+2
@@ -1 +1,3 @@
|
||||
coverage.txt
|
||||
.zed
|
||||
.idea
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
language: go
|
||||
|
||||
go:
|
||||
- 1.15.x
|
||||
- master
|
||||
|
||||
arch:
|
||||
- amd64
|
||||
- ppc64le
|
||||
|
||||
before_install:
|
||||
- go get -t -v ./...
|
||||
|
||||
script:
|
||||
- go test -race -coverprofile=coverage.txt -covermode=atomic
|
||||
|
||||
after_success:
|
||||
- bash <(curl -s https://codecov.io/bash)
|
||||
+1
-1
@@ -13,7 +13,7 @@ GOOS=js GOARCH=wasm go build -o yourfile.wasm
|
||||
|
||||
You also need 5 other files in the same directory as the wasm. Four (`tcell.html`, `tcell.js`, `termstyle.css`, and `beep.wav`) are provided in the `webfiles` directory. The last one, `wasm_exec.js`, can be copied from GOROOT into the current directory by executing
|
||||
```sh
|
||||
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./
|
||||
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" ./
|
||||
```
|
||||
|
||||
In `tcell.js`, you also need to change the constant
|
||||
|
||||
+25
-134
@@ -7,14 +7,14 @@ It was inspired by _termbox_, but includes many additional improvements.
|
||||
|
||||
[](https://stand-with-ukraine.pp.ua)
|
||||
[](https://github.com/gdamore/tcell/actions/workflows/linux.yml)
|
||||
[](https://github.com/gdamore/tcell/actions/workflows/windows.yml)
|
||||
[](https://github.com/gdamore/tcell/actions/workflows/windows.yml)
|
||||
[](https://github.com/gdamore/tcell/actions/workflows/webasm.yml)
|
||||
[](https://github.com/gdamore/tcell/blob/master/LICENSE)
|
||||
[](https://pkg.go.dev/github.com/gdamore/tcell/v2)
|
||||
[](https://discord.gg/urTTxDN)
|
||||
[](https://codecov.io/gh/gdamore/tcell)
|
||||
[](https://goreportcard.com/report/github.com/gdamore/tcell/v2)
|
||||
|
||||
Please see [here](UKRAINE.md) for an important message for the people of Russia.
|
||||
[](https://github.com/gdamore/tcell/releases)
|
||||
|
||||
NOTE: This is version 2 of _Tcell_. There are breaking changes relative to version 1.
|
||||
Version 1.x remains available using the import `github.com/gdamore/tcell`.
|
||||
@@ -25,59 +25,9 @@ A brief, and still somewhat rough, [tutorial](TUTORIAL.md) is available.
|
||||
|
||||
## Examples
|
||||
|
||||
- [proxima5](https://github.com/gdamore/proxima5) - space shooter ([video](https://youtu.be/jNxKTCmY_bQ))
|
||||
- [govisor](https://github.com/gdamore/govisor) - service management UI ([screenshot](http://2.bp.blogspot.com/--OsvnfzSNow/Vf7aqMw3zXI/AAAAAAAAARo/uOMtOvw4Sbg/s1600/Screen%2BShot%2B2015-09-20%2Bat%2B9.08.41%2BAM.png))
|
||||
- mouse demo - included mouse test ([screenshot](http://2.bp.blogspot.com/-fWvW5opT0es/VhIdItdKqJI/AAAAAAAAATE/7Ojc0L1SpB0/s1600/Screen%2BShot%2B2015-10-04%2Bat%2B11.47.13%2BPM.png))
|
||||
- [gomatrix](https://github.com/gdamore/gomatrix) - converted from Termbox
|
||||
- [micro](https://github.com/zyedidia/micro/) - lightweight text editor with syntax-highlighting and themes
|
||||
- [godu](https://github.com/viktomas/godu) - utility to discover large files/folders
|
||||
- [tview](https://github.com/rivo/tview/) - rich interactive widgets
|
||||
- [cview](https://code.rocketnine.space/tslocum/cview) - user interface toolkit (fork of _tview_)
|
||||
- [awesome gocui](https://github.com/awesome-gocui/gocui) - Go Console User Interface
|
||||
- [gomandelbrot](https://github.com/rgm3/gomandelbrot) - Mandelbrot!
|
||||
- [WTF](https://github.com/senorprogrammer/wtf) - personal information dashboard
|
||||
- [browsh](https://github.com/browsh-org/browsh) - modern web browser ([video](https://www.youtube.com/watch?v=HZq86XfBoRo))
|
||||
- [go-life](https://github.com/sachaos/go-life) - Conway's Game of Life
|
||||
- [gowid](https://github.com/gcla/gowid) - compositional widgets for terminal UIs, inspired by _urwid_
|
||||
- [termshark](https://termshark.io) - interface for _tshark_, inspired by Wireshark, built on _gowid_
|
||||
- [go-tetris](https://github.com/MichaelS11/go-tetris) - Go Tetris with AI option
|
||||
- [fzf](https://github.com/junegunn/fzf) - command-line fuzzy finder
|
||||
- [ascii-fluid](https://github.com/esimov/ascii-fluid) - fluid simulation controlled by webcam
|
||||
- [cbind](https://code.rocketnine.space/tslocum/cbind) - key event encoding, decoding and handling
|
||||
- [tpong](https://github.com/spinzed/tpong) - old-school Pong
|
||||
- [aerc](https://git.sr.ht/~sircmpwn/aerc) - email client
|
||||
- [tblogs](https://github.com/ezeoleaf/tblogs) - development blogs reader
|
||||
- [spinc](https://github.com/lallassu/spinc) - _irssi_ inspired chat application for Cisco Spark/WebEx
|
||||
- [gorss](https://github.com/lallassu/gorss) - RSS/Atom feed reader
|
||||
- [memoryalike](https://github.com/Bios-Marcel/memoryalike) - memorization game
|
||||
- [lf](https://github.com/gokcehan/lf) - file manager
|
||||
- [goful](https://github.com/anmitsu/goful) - CUI file manager
|
||||
- [gokeybr](https://github.com/bunyk/gokeybr) - deliberately practice your typing
|
||||
- [gonano](https://github.com/jbaramidze/gonano) - editor, mimics _nano_
|
||||
- [uchess](https://github.com/tmountain/uchess) - UCI chess client
|
||||
- [min](https://github.com/a-h/min) - Gemini browser
|
||||
- [ov](https://github.com/noborus/ov) - file pager
|
||||
- [tmux-wormhole](https://github.com/gcla/tmux-wormhole) - _tmux_ plugin to transfer files
|
||||
- [gruid-tcell](https://github.com/anaseto/gruid-tcell) - driver for the grid based UI and game framework
|
||||
- [aretext](https://github.com/aretext/aretext) - minimalist text editor with _vim_ key bindings
|
||||
- [sync](https://github.com/kyprifog/sync) - GitHub repo synchronization tool
|
||||
- [statusbar](https://github.com/kyprifog/statusbar) - statusbar motivation tool for tracking periodic tasks/goals
|
||||
- [todo](https://github.com/kyprifog/todo) - simple todo app
|
||||
- [gosnakego](https://github.com/liweiyi88/gosnakego) - a snake game
|
||||
- [gbb](https://github.com/sdemingo/gbb) - A classical bulletin board app for tildes or public unix servers
|
||||
- [lil](https://github.com/andrievsky/lil) - A simple and flexible interface for any service by implementing only list and get operations
|
||||
- [hero.go](https://github.com/barisbll/hero.go) - 2d monster shooter ([video](https://user-images.githubusercontent.com/40062673/277157369-240d7606-b471-4aa1-8c54-4379a513122b.mp4))
|
||||
- [go-tetris](https://github.com/aaronriekenberg/go-tetris) - simple tetris game for native terminal and WASM using github actions+pages
|
||||
- [oddshub](https://github.com/dos-2/oddshub) - A TUI designed for analyzing sports betting odds
|
||||
A number of example are posted up on our [Gallery](https://github.com/gdamore/tcell/wikis/Gallery/).
|
||||
|
||||
## Pure Go Terminfo Database
|
||||
|
||||
_Tcell_ includes a full parser and expander for terminfo capability strings,
|
||||
so that it can avoid hard coding escape strings for formatting. It also favors
|
||||
portability, and includes support for all POSIX systems.
|
||||
|
||||
The database is also flexible & extensible, and can be modified by either running
|
||||
a program to build the entire database, or an entry for just a single terminal.
|
||||
Let us know if you want to add your masterpiece to the list!
|
||||
|
||||
## More Portable
|
||||
|
||||
@@ -85,13 +35,10 @@ _Tcell_ is portable to a wide variety of systems, and is pure Go, without
|
||||
any need for CGO.
|
||||
_Tcell_ is believed to work with mainstream systems officially supported by golang.
|
||||
|
||||
## No Async IO
|
||||
|
||||
_Tcell_ is able to operate without requiring `SIGIO` signals (unlike _termbox_),
|
||||
or asynchronous I/O, and can instead use standard Go file objects and Go routines.
|
||||
This means it should be safe, especially for
|
||||
use with programs that use exec, or otherwise need to manipulate the tty streams.
|
||||
This model is also much closer to idiomatic Go, leading to fewer surprises.
|
||||
Following the Go support policy, _Tcell_ officially only supports the current ("stable") version of go,
|
||||
and the version immediately prior ("oldstable"). This policy is necessary to make sure that we can
|
||||
update dependencies to pick up security fixes and new features, and it allows us to adopt changes
|
||||
(such as library and language features) that are only supported in newer versions of Go.
|
||||
|
||||
## Rich Unicode & non-Unicode support
|
||||
|
||||
@@ -111,29 +58,11 @@ drawing certain characters.
|
||||
_Tcell_ also has richer support for a larger number of special keys that some
|
||||
terminals can send.
|
||||
|
||||
## Better Color Handling
|
||||
|
||||
_Tcell_ will respect your terminal's color space as specified within your terminfo entries.
|
||||
For example attempts to emit color sequences on VT100 terminals
|
||||
won't result in unintended consequences.
|
||||
|
||||
In legacy Windows mode, _Tcell_ supports 16 colors, bold, dim, and reverse,
|
||||
instead of just termbox's 8 colors with reverse. (Note that there is some
|
||||
conflation with bold/dim and colors.)
|
||||
Modern Windows 10 can benefit from much richer colors however.
|
||||
|
||||
_Tcell_ maps 16 colors down to 8, for terminals that need it.
|
||||
(The upper 8 colors are just brighter versions of the lower 8.)
|
||||
|
||||
## Better Mouse Support
|
||||
|
||||
_Tcell_ supports enhanced mouse tracking mode, so your application can receive
|
||||
regular mouse motion events, and wheel events, if your terminal supports it.
|
||||
|
||||
(Note: The Windows 10 Terminal application suffers from a flaw in this regard,
|
||||
and does not support mouse interaction. The stock Windows 10 console host
|
||||
fired up with cmd.exe or PowerShell works fine however.)
|
||||
|
||||
## _Termbox_ Compatibility
|
||||
|
||||
A compatibility layer for _termbox_ is provided in the `compat` directory.
|
||||
@@ -152,15 +81,15 @@ If you're lazy, and want them all anyway, see the `encoding` sub-directory.
|
||||
|
||||
## Wide & Combining Characters
|
||||
|
||||
The `SetContent()` API takes a primary rune, and an optional list of combining runes.
|
||||
If any of the runes is a wide (East Asian) rune occupying two cells,
|
||||
then the library will skip output from the following cell. Care must be
|
||||
taken in the application to avoid explicitly attempting to set content in the
|
||||
next cell, otherwise the results are undefined. (Normally the wide character
|
||||
is displayed, and the other character is not; do not depend on that behavior.)
|
||||
The `Put()` API takes a string, which should be legal UTF-8, and displays
|
||||
the first grapheme (which may composed of multiple runes). It returns the
|
||||
actual width displayed, which can be used to advance the column positiion
|
||||
for the next display grapheme. Alternatively, `PutStr()` or `PutStrStyled()`
|
||||
can be used to display a single line of text (which will be clipped at the
|
||||
edge of the screen).
|
||||
|
||||
Older terminal applications (especially on systems like Windows 8) lack support
|
||||
for advanced Unicode, and thus may not fare well.
|
||||
If a second character is displayed immediately in the cell adjacent to a
|
||||
wide character (offset by one instead of by two), then the results are undefined.
|
||||
|
||||
## Colors
|
||||
|
||||
@@ -175,11 +104,7 @@ a ticket.
|
||||
|
||||
_Tcell_ _supports 24-bit color!_ (That is, if your terminal can support it.)
|
||||
|
||||
NOTE: Technically the approach of using 24-bit RGB values for color is more
|
||||
accurately described as "direct color", but most people use the term "true color".
|
||||
We follow the (inaccurate) common convention.
|
||||
|
||||
There are a few ways you can enable (or disable) true color.
|
||||
There are a few ways you can enable (or disable) 24-bit color.
|
||||
|
||||
- For many terminals, we can detect it automatically if your terminal
|
||||
includes the `RGB` or `Tc` capabilities (or rather it did when the database
|
||||
@@ -197,8 +122,8 @@ There are a few ways you can enable (or disable) true color.
|
||||
- You can disable 24-bit color by setting `TCELL_TRUECOLOR=disable` in your
|
||||
environment.
|
||||
|
||||
When using TrueColor, programs will display the colors that the programmer
|
||||
intended, overriding any "`themes`" you may have set in your terminal
|
||||
When using 24-bit color, programs will display the colors that the programmer
|
||||
intended, overriding any "`themes`" the user may have set in their terminal
|
||||
emulator. (For some cases, accurate color fidelity is more important
|
||||
than respecting themes. For other cases, such as typical text apps that
|
||||
only use a few colors, its more desirable to respect the themes that
|
||||
@@ -209,38 +134,10 @@ the user has established.)
|
||||
Reasonable attempts have been made to minimize sending data to terminals,
|
||||
avoiding repeated sequences or drawing the same cell on refresh updates.
|
||||
|
||||
## Terminfo
|
||||
|
||||
(Not relevant for Windows users.)
|
||||
|
||||
The Terminfo implementation operates with a built-in database.
|
||||
This should satisfy most users. However, it can also (on systems
|
||||
with ncurses installed), dynamically parse the output from `infocmp`
|
||||
for terminals it does not already know about.
|
||||
|
||||
See the `terminfo/` directory for more information about generating
|
||||
new entries for the built-in database.
|
||||
|
||||
_Tcell_ requires that the terminal support the `cup` mode of cursor addressing.
|
||||
Ancient terminals without the ability to position the cursor directly
|
||||
are not supported.
|
||||
This is unlikely to be a problem; such terminals have not been mass-produced
|
||||
since the early 1970s.
|
||||
|
||||
## Mouse Support
|
||||
|
||||
Mouse support is detected via the `kmous` terminfo variable, however,
|
||||
enablement/disablement and decoding mouse events is done using hard coded
|
||||
sequences based on the XTerm X11 model. All popular
|
||||
terminals with mouse tracking support this model. (Full terminfo support
|
||||
is not possible as terminfo sequences are not defined.)
|
||||
|
||||
On Windows, the mouse works normally.
|
||||
|
||||
Mouse wheel buttons on various terminals are known to work, but the support
|
||||
in terminal emulators, as well as support for various buttons and
|
||||
live mouse tracking, varies widely.
|
||||
Modern _xterm_, macOS _Terminal_, and _iTerm_ all work well.
|
||||
Mouse tracking, buttons, and even wheel mice works fine on most terminal
|
||||
emulators, as well as Windows.
|
||||
|
||||
## Bracketed Paste
|
||||
|
||||
@@ -265,22 +162,16 @@ platforms (e.g., AIX) may need to be added. Pull requests are welcome!
|
||||
|
||||
Windows console mode applications are supported.
|
||||
|
||||
Modern console applications like ConEmu and the Windows 10 terminal,
|
||||
Modern console applications like ConEmu and the Windows Terminal,
|
||||
support all the good features (resize, mouse tracking, etc.)
|
||||
|
||||
### WASM
|
||||
|
||||
WASM is supported, but needs additional setup detailed in [README-wasm](README-wasm.md).
|
||||
|
||||
### Plan9 and others
|
||||
### Plan9 and its variants
|
||||
|
||||
These platforms won't work, but compilation stubs are supplied
|
||||
for folks that want to include parts of this in software for those
|
||||
platforms. The Simulation screen works, but as _Tcell_ doesn't know how to
|
||||
allocate a real screen object on those platforms, `NewScreen()` will fail.
|
||||
|
||||
If anyone has wisdom about how to improve support for these,
|
||||
please let me know. PRs are especially welcome.
|
||||
Plan 9 is supported on a limited basis. The Plan 9 backend opens `/dev/cons` for I/O, enables raw mode by writing `rawon`/`rawoff` to `/dev/consctl`, watches `/dev/wctl` for resize notifications, and then constructs a **terminfo-backed** `Screen` (so `NewScreen` works as on other platforms). Typical usage is inside `vt(1)` with `TERM=vt100`. Expect **monochrome text** and **no mouse reporting** under stock `vt(1)` (it generally does not emit ANSI color or xterm mouse sequences). If a Plan 9 terminal supplies ANSI color escape sequences and xterm-style mouse reporting, color can be picked up via **terminfo** and mouse support could be added by wiring those sequences into the Plan 9 TTY path; contributions that improve terminal detection and broaden feature support are welcome.
|
||||
|
||||
### Commercial Support
|
||||
|
||||
|
||||
+36
-21
@@ -107,23 +107,30 @@ s.SetStyle(defStyle)
|
||||
s.Clear()
|
||||
```
|
||||
|
||||
Text may be drawn on the screen using `SetContent`.
|
||||
Text may be drawn on the screen using `Put`, `PutStr`, or `PutStrStyled`.
|
||||
|
||||
```go
|
||||
s.SetContent(0, 0, 'H', nil, defStyle)
|
||||
s.SetContent(1, 0, 'i', nil, defStyle)
|
||||
s.SetContent(2, 0, '!', nil, defStyle)
|
||||
s.Put(0, 0, 'H', defStyle)
|
||||
s.Put(1, 0, 'i', defStyle)
|
||||
s.Put(2, 0, '!', defStyle)
|
||||
```
|
||||
|
||||
To draw text more easily, define a render function.
|
||||
which is equivalent to
|
||||
|
||||
```go
|
||||
s.PutStrStyled(0, 0, "Hi!", defStyle)
|
||||
````
|
||||
|
||||
To draw text more easily with wrapping, define a render function.
|
||||
|
||||
```go
|
||||
func drawText(s tcell.Screen, x1, y1, x2, y2 int, style tcell.Style, text string) {
|
||||
row := y1
|
||||
col := x1
|
||||
for _, r := range []rune(text) {
|
||||
s.SetContent(col, row, r, nil, style)
|
||||
col++
|
||||
var width int
|
||||
for text != "" {
|
||||
text, width = s.Put(col, row, text, style)
|
||||
col += width
|
||||
if col >= x2 {
|
||||
row++
|
||||
col = x1
|
||||
@@ -131,6 +138,10 @@ func drawText(s tcell.Screen, x1, y1, x2, y2 int, style tcell.Style, text string
|
||||
if row > y2 {
|
||||
break
|
||||
}
|
||||
if width == 0 {
|
||||
// incomplete grapheme at end of string
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -178,9 +189,10 @@ import (
|
||||
func drawText(s tcell.Screen, x1, y1, x2, y2 int, style tcell.Style, text string) {
|
||||
row := y1
|
||||
col := x1
|
||||
for _, r := range []rune(text) {
|
||||
s.SetContent(col, row, r, nil, style)
|
||||
col++
|
||||
var width int
|
||||
for text != "" {
|
||||
text, width = s.Put(col, row, text, style)
|
||||
col += width
|
||||
if col >= x2 {
|
||||
row++
|
||||
col = x1
|
||||
@@ -188,6 +200,10 @@ func drawText(s tcell.Screen, x1, y1, x2, y2 int, style tcell.Style, text string
|
||||
if row > y2 {
|
||||
break
|
||||
}
|
||||
if width == 0 {
|
||||
// incomplete grapheme at end of string
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,26 +218,26 @@ func drawBox(s tcell.Screen, x1, y1, x2, y2 int, style tcell.Style, text string)
|
||||
// Fill background
|
||||
for row := y1; row <= y2; row++ {
|
||||
for col := x1; col <= x2; col++ {
|
||||
s.SetContent(col, row, ' ', nil, style)
|
||||
s.Put(col, row, " ", style)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw borders
|
||||
for col := x1; col <= x2; col++ {
|
||||
s.SetContent(col, y1, tcell.RuneHLine, nil, style)
|
||||
s.SetContent(col, y2, tcell.RuneHLine, nil, style)
|
||||
s.Put(col, y1, string(tcell.RuneHLine), style)
|
||||
s.Put(col, y2, string(tcell.RuneHLine), style)
|
||||
}
|
||||
for row := y1 + 1; row < y2; row++ {
|
||||
s.SetContent(x1, row, tcell.RuneVLine, nil, style)
|
||||
s.SetContent(x2, row, tcell.RuneVLine, nil, style)
|
||||
s.Put(x1, row, string(tcell.RuneVLine), style)
|
||||
s.Put(x2, row, string(tcell.RuneVLine), style)
|
||||
}
|
||||
|
||||
// Only draw corners if necessary
|
||||
if y1 != y2 && x1 != x2 {
|
||||
s.SetContent(x1, y1, tcell.RuneULCorner, nil, style)
|
||||
s.SetContent(x2, y1, tcell.RuneURCorner, nil, style)
|
||||
s.SetContent(x1, y2, tcell.RuneLLCorner, nil, style)
|
||||
s.SetContent(x2, y2, tcell.RuneLRCorner, nil, style)
|
||||
s.Put(x1, y1, string(tcell.RuneULCorner), style)
|
||||
s.Put(x2, y1, string(tcell.RuneURCorner), style)
|
||||
s.Put(x1, y2, string(tcell.RuneLLCorner), style)
|
||||
s.Put(x2, y2, string(tcell.RuneLRCorner), style)
|
||||
}
|
||||
|
||||
drawText(s, x1+1, y1+1, x2-1, y2-1, style, text)
|
||||
@@ -310,4 +326,3 @@ func main() {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
# Ukraine, Russia, and a World Tragedy
|
||||
|
||||
## A message to those inside Russia
|
||||
|
||||
### Written March 4, 2022.
|
||||
|
||||
It is with a very heavy heart that I write this. I am normally opposed to the use of open source
|
||||
projects to communicate political positions or advocate for things outside the immediate relevancy
|
||||
to that project.
|
||||
|
||||
However, the events occurring in Ukraine, and specifically the unprecedented invasion of Ukraine by
|
||||
Russian forces operating under orders from Russian President Vladimir Putin compel me to speak out.
|
||||
|
||||
Those who know me, know that I have family, friends, and colleagues in Russia, and Ukraine both. My closest friends
|
||||
have historically been Russian friends my wife's hometown of Chelyabinsk. I myself have in the past
|
||||
frequently traveled to Russia, and indeed operated a software development firm with offices in St. Petersburg.
|
||||
I had a special kinship with Russia and its people.
|
||||
|
||||
I say "had", because I fear that the actions of Putin, and the massive disinformation campaign that his regime
|
||||
has waged inside Russia, mean that it's likely that I won't see those friends again. At present, I'm not sure
|
||||
my wife will see her own mother again. We no longer feel it's safe for either of us to return Russia given
|
||||
actions taken by the regime to crack down on those who express disagreement.
|
||||
|
||||
Russian citizens are being led to believe it is acting purely defensively, and that only legitimate military
|
||||
targets are being targeted, and that all the information we have received in the West are fakes.
|
||||
|
||||
I am confident that nothing could be further from the truth.
|
||||
|
||||
This has caused many in Russia, including people whom I respect and believe to be smarter than this, to
|
||||
stand by Putin, and endorse his actions. The claim is that the entirety of NATO is operating at the behest
|
||||
of the USA, and that the entirety of Europe was poised to attack Russia. While this is clearly absurd to those
|
||||
of us with any understanding of western politics, Russian citizens are being fed this lie, and believing it.
|
||||
|
||||
If you're reading this from inside Russia -- YOU are the person that I hope this message reaches. Your
|
||||
government is LYING to you. Of course, all governments lie all the time. But consider this. Almost the
|
||||
entire world has condemned the invasion of Ukraine as criminal, and has applied sanctions. Even countries
|
||||
which have poor relations with the US sanctioning Russia, as well as nations which historically have remained
|
||||
neutral. (Famously neutral -- even during World War II, Switzerland has acted to apply sanctions in
|
||||
concert with the rest of the world.)
|
||||
|
||||
Ask yourself, why does Putin fear a free press so much, if what he says is true? Why the crack-downs on
|
||||
children expressing only a desire for peace with Ukraine? Why would the entire world unified against him,
|
||||
if Putin was in the right? Why would the only countries that stood with Russia against
|
||||
the UN resolution to condemn these acts as crimes be Belarus, North Korea, and Syria? Even countries normally
|
||||
allied to Russia could not bring themselves to do more than abstain from the vote to condemn it.
|
||||
|
||||
To be clear, I do not claim that the actions taken by the West or by the Ukrainian government were completely
|
||||
blameless. On the contrary, I understand that Western media is biased, and the truth is rarely exactly
|
||||
as reported. I believe that there is a kernel of truth in the claims of fascists and ultra-nationalist
|
||||
militias operating in Ukraine and specifically Donbas. However, I am also equally certain that Putin's
|
||||
response is out of proportion, and that concerns about such militias are principally just a pretext to justify
|
||||
an invasion.
|
||||
|
||||
Europe is at war, unlike we've seen in my lifetime. The world is more divided, and closer to nuclear holocaust
|
||||
than it has been since the Cold War. And that is 100% the fault of Putin.
|
||||
|
||||
While Putin remains in power, there cannot really be any way for Russian international relations to return
|
||||
to normal. Putin has set your country on a path to return to the Cold War, likely because he fancies himself
|
||||
to be a new Stalin. However, unlike the Soviet Union, the Russian economy does not have the wherewithal to
|
||||
stand on its own, and the invasion of Ukraine has fully ensured that Russia will not find any friends anywhere
|
||||
else in Europe, and probably few places in Asia.
|
||||
|
||||
The *only* paths forward for Russia are either a Russia without Putin (and those who would support his agenda),
|
||||
or a complete breakdown of Russian prosperity, likely followed by the increasing international conflict that will
|
||||
be the natural escalation from a country that is isolated and impoverished. Those of us observing from the West are
|
||||
gravely concerned, because we cannot see any end to this madness that does not result in nuclear conflict,
|
||||
unless from within.
|
||||
|
||||
In the meantime, the worst prices will be paid for by innocents in Ukraine, and by young Russian mean
|
||||
forced to carry out the orders of Putin's corrupt regime.
|
||||
|
||||
And *that* is why I write this -- to appeal to those within Russia to open your eyes, and think with
|
||||
your minds. It is right and proper to be proud of your country and its rich heritage. But it is also
|
||||
right and proper to look for ways to save it from the ruinous path that its current leadership has set it upon,
|
||||
and to recognize when that leadership is no longer acting in interest of the country or its people.
|
||||
|
||||
- Garrett D'Amore, March 4, 2022
|
||||
+81
-66
@@ -1,4 +1,4 @@
|
||||
// Copyright 2024 The TCell Authors
|
||||
// Copyright 2025 The TCell Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
@@ -15,23 +15,30 @@
|
||||
package tcell
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
|
||||
runewidth "github.com/mattn/go-runewidth"
|
||||
"github.com/rivo/uniseg"
|
||||
)
|
||||
|
||||
type cell struct {
|
||||
currMain rune
|
||||
currComb []rune
|
||||
currStr string
|
||||
lastStr string
|
||||
currStyle Style
|
||||
lastMain rune
|
||||
lastStyle Style
|
||||
lastComb []rune
|
||||
width int
|
||||
lock bool
|
||||
}
|
||||
|
||||
func (c *cell) setDirty(dirty bool) {
|
||||
if dirty {
|
||||
c.lastStr = ""
|
||||
} else {
|
||||
if c.currStr == "" {
|
||||
c.currStr = " "
|
||||
}
|
||||
c.lastStr = c.currStr
|
||||
c.lastStyle = c.currStyle
|
||||
}
|
||||
}
|
||||
|
||||
// CellBuffer represents a two-dimensional array of character cells.
|
||||
// This is primarily intended for use by Screen implementors; it
|
||||
// contains much of the common code they need. To create one, just
|
||||
@@ -48,28 +55,47 @@ type CellBuffer struct {
|
||||
// and style) for a cell at a given location. If the background or
|
||||
// foreground of the style is set to ColorNone, then the respective
|
||||
// color is left un changed.
|
||||
func (cb *CellBuffer) SetContent(x int, y int,
|
||||
mainc rune, combc []rune, style Style,
|
||||
) {
|
||||
//
|
||||
// Deprecated: Use Put instead, which this is implemented in terms of.
|
||||
func (cb *CellBuffer) SetContent(x int, y int, mainc rune, combc []rune, style Style) {
|
||||
cb.Put(x, y, string(append([]rune{mainc}, combc...)), style)
|
||||
}
|
||||
|
||||
// Put a single styled grapheme using the given string and style
|
||||
// at the same location. Note that only the first grapheme in the string
|
||||
// will bre displayed, using only the 1 or 2 (depending on width) cells
|
||||
// located at x, y. It returns the rest of the string, and the width used.
|
||||
func (cb *CellBuffer) Put(x int, y int, str string, style Style) (string, int) {
|
||||
var width int = 0
|
||||
if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
|
||||
var cl string
|
||||
c := &cb.cells[(y*cb.w)+x]
|
||||
state := -1
|
||||
for width == 0 && str != "" {
|
||||
var g string
|
||||
g, str, width, state = uniseg.FirstGraphemeClusterInString(str, state)
|
||||
cl += g
|
||||
if g == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Wide characters: we want to mark the "wide" cells
|
||||
// dirty as well as the base cell, to make sure we consider
|
||||
// both cells as dirty together. We only need to do this
|
||||
// if we're changing content
|
||||
if (c.width > 0) && (mainc != c.currMain || len(combc) != len(c.currComb) || (len(combc) > 0 && !reflect.DeepEqual(combc, c.currComb))) {
|
||||
for i := 0; i < c.width; i++ {
|
||||
if width > 0 && cl != c.currStr {
|
||||
// Prevent unnecessary boundchecks for first cell, since we already
|
||||
// received that one.
|
||||
c.setDirty(true)
|
||||
for i := 1; i < width; i++ {
|
||||
cb.SetDirty(x+i, y, true)
|
||||
}
|
||||
}
|
||||
|
||||
c.currComb = append([]rune{}, combc...)
|
||||
c.currStr = cl
|
||||
c.width = width
|
||||
|
||||
if c.currMain != mainc {
|
||||
c.width = runewidth.RuneWidth(mainc)
|
||||
}
|
||||
c.currMain = mainc
|
||||
if style.fg == ColorNone {
|
||||
style.fg = c.currStyle.fg
|
||||
}
|
||||
@@ -78,23 +104,45 @@ func (cb *CellBuffer) SetContent(x int, y int,
|
||||
}
|
||||
c.currStyle = style
|
||||
}
|
||||
return str, width
|
||||
}
|
||||
|
||||
// Get the contents of a character cell (or two adjacent cells), including the
|
||||
// the style and the display width in cells. (The width can be either 1, normally,
|
||||
// or 2 for East Asian full-width characters. If the width is 0, then the cell is
|
||||
// is empty.)
|
||||
func (cb *CellBuffer) Get(x, y int) (string, Style, int) {
|
||||
var style Style
|
||||
var width int
|
||||
var str string
|
||||
if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
|
||||
c := &cb.cells[(y*cb.w)+x]
|
||||
str, style = c.currStr, c.currStyle
|
||||
if width = c.width; width == 0 || str == "" {
|
||||
width = 1
|
||||
str = " "
|
||||
}
|
||||
}
|
||||
return str, style, width
|
||||
}
|
||||
|
||||
// GetContent returns the contents of a character cell, including the
|
||||
// primary rune, any combining character runes (which will usually be
|
||||
// nil), the style, and the display width in cells. (The width can be
|
||||
// either 1, normally, or 2 for East Asian full-width characters.)
|
||||
//
|
||||
// Deprecated: Use Get, which this implemented in terms of.
|
||||
func (cb *CellBuffer) GetContent(x, y int) (rune, []rune, Style, int) {
|
||||
var mainc rune
|
||||
var combc []rune
|
||||
var style Style
|
||||
var width int
|
||||
if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
|
||||
c := &cb.cells[(y*cb.w)+x]
|
||||
mainc, combc, style = c.currMain, c.currComb, c.currStyle
|
||||
if width = c.width; width == 0 || mainc < ' ' {
|
||||
width = 1
|
||||
mainc = ' '
|
||||
var mainc rune
|
||||
var combc []rune
|
||||
str, style, width := cb.Get(x, y)
|
||||
for i, r := range str {
|
||||
if i == 0 {
|
||||
mainc = r
|
||||
} else {
|
||||
combc = append(combc, r)
|
||||
}
|
||||
}
|
||||
return mainc, combc, style, width
|
||||
@@ -108,7 +156,7 @@ func (cb *CellBuffer) Size() (int, int) {
|
||||
// Invalidate marks all characters within the buffer as dirty.
|
||||
func (cb *CellBuffer) Invalidate() {
|
||||
for i := range cb.cells {
|
||||
cb.cells[i].lastMain = rune(0)
|
||||
cb.cells[i].lastStr = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,23 +169,12 @@ func (cb *CellBuffer) Dirty(x, y int) bool {
|
||||
if c.lock {
|
||||
return false
|
||||
}
|
||||
if c.lastMain == rune(0) {
|
||||
return true
|
||||
}
|
||||
if c.lastMain != c.currMain {
|
||||
return true
|
||||
}
|
||||
if c.lastStyle != c.currStyle {
|
||||
return true
|
||||
}
|
||||
if len(c.lastComb) != len(c.currComb) {
|
||||
if c.lastStr != c.currStr {
|
||||
return true
|
||||
}
|
||||
for i := range c.lastComb {
|
||||
if c.lastComb[i] != c.currComb[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -148,16 +185,7 @@ func (cb *CellBuffer) Dirty(x, y int) bool {
|
||||
func (cb *CellBuffer) SetDirty(x, y int, dirty bool) {
|
||||
if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
|
||||
c := &cb.cells[(y*cb.w)+x]
|
||||
if dirty {
|
||||
c.lastMain = rune(0)
|
||||
} else {
|
||||
if c.currMain == rune(0) {
|
||||
c.currMain = ' '
|
||||
}
|
||||
c.lastMain = c.currMain
|
||||
c.lastComb = c.currComb
|
||||
c.lastStyle = c.currStyle
|
||||
}
|
||||
c.setDirty(dirty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,11 +231,10 @@ func (cb *CellBuffer) Resize(w, h int) {
|
||||
for x := 0; x < w && x < cb.w; x++ {
|
||||
oc := &cb.cells[(y*cb.w)+x]
|
||||
nc := &newc[(y*w)+x]
|
||||
nc.currMain = oc.currMain
|
||||
nc.currComb = oc.currComb
|
||||
nc.currStr = oc.currStr
|
||||
nc.currStyle = oc.currStyle
|
||||
nc.width = oc.width
|
||||
nc.lastMain = rune(0)
|
||||
nc.lastStr = ""
|
||||
}
|
||||
}
|
||||
cb.cells = newc
|
||||
@@ -223,8 +250,7 @@ func (cb *CellBuffer) Resize(w, h int) {
|
||||
func (cb *CellBuffer) Fill(r rune, style Style) {
|
||||
for i := range cb.cells {
|
||||
c := &cb.cells[i]
|
||||
c.currMain = r
|
||||
c.currComb = nil
|
||||
c.currStr = string(r)
|
||||
cs := style
|
||||
if cs.fg == ColorNone {
|
||||
cs.fg = c.currStyle.fg
|
||||
@@ -236,14 +262,3 @@ func (cb *CellBuffer) Fill(r rune, style Style) {
|
||||
c.width = 1
|
||||
}
|
||||
}
|
||||
|
||||
var runeConfig *runewidth.Condition
|
||||
|
||||
func init() {
|
||||
// The defaults for the runewidth package are poorly chosen for terminal
|
||||
// applications. We however will honor the setting in the environment if
|
||||
// it is set.
|
||||
if os.Getenv("RUNEWIDTH_EASTASIAN") == "" {
|
||||
runewidth.DefaultCondition.EastAsianWidth = false
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
//go:build plan9
|
||||
// +build plan9
|
||||
|
||||
// Copyright 2025 The TCell Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package tcell
|
||||
|
||||
// Plan 9 uses UTF-8 system-wide, so we return "UTF-8" unconditionally.
|
||||
func getCharset() string {
|
||||
return "UTF-8"
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
//go:build plan9 || nacl
|
||||
// +build plan9 nacl
|
||||
//go:build nacl
|
||||
// +build nacl
|
||||
|
||||
// Copyright 2015 The TCell Authors
|
||||
//
|
||||
|
||||
+1
-1
@@ -18,5 +18,5 @@
|
||||
package tcell
|
||||
|
||||
func getCharset() string {
|
||||
return "UTF-16"
|
||||
return "UTF-8"
|
||||
}
|
||||
|
||||
+90
-213
@@ -1,7 +1,7 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
// Copyright 2024 The TCell Authors
|
||||
// Copyright 2025 The TCell Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
@@ -38,7 +38,6 @@ type cScreen struct {
|
||||
cury int
|
||||
style Style
|
||||
fini bool
|
||||
vten bool
|
||||
truecolor bool
|
||||
running bool
|
||||
disableAlt bool // disable the alternate screen
|
||||
@@ -106,7 +105,6 @@ var winColors = map[Color]Color{
|
||||
}
|
||||
|
||||
var (
|
||||
k32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
u32 = syscall.NewLazyDLL("user32.dll")
|
||||
)
|
||||
|
||||
@@ -117,18 +115,8 @@ var (
|
||||
// characters (Unicode) are in use. The documentation refers to them
|
||||
// without this suffix, as the resolution is made via preprocessor.
|
||||
var (
|
||||
procReadConsoleInput = k32.NewProc("ReadConsoleInputW")
|
||||
procWaitForMultipleObjects = k32.NewProc("WaitForMultipleObjects")
|
||||
procCreateEvent = k32.NewProc("CreateEventW")
|
||||
procSetEvent = k32.NewProc("SetEvent")
|
||||
procGetConsoleCursorInfo = k32.NewProc("GetConsoleCursorInfo")
|
||||
procSetConsoleCursorInfo = k32.NewProc("SetConsoleCursorInfo")
|
||||
procSetConsoleCursorPosition = k32.NewProc("SetConsoleCursorPosition")
|
||||
procSetConsoleMode = k32.NewProc("SetConsoleMode")
|
||||
procGetConsoleMode = k32.NewProc("GetConsoleMode")
|
||||
procGetConsoleScreenBufferInfo = k32.NewProc("GetConsoleScreenBufferInfo")
|
||||
procFillConsoleOutputAttribute = k32.NewProc("FillConsoleOutputAttribute")
|
||||
procFillConsoleOutputCharacter = k32.NewProc("FillConsoleOutputCharacterW")
|
||||
procSetConsoleWindowInfo = k32.NewProc("SetConsoleWindowInfo")
|
||||
procSetConsoleScreenBufferSize = k32.NewProc("SetConsoleScreenBufferSize")
|
||||
procSetConsoleTextAttribute = k32.NewProc("SetConsoleTextAttribute")
|
||||
@@ -195,6 +183,10 @@ var vtCursorStyles = map[CursorStyle]string{
|
||||
// NewConsoleScreen returns a Screen for the Windows console associated
|
||||
// with the current process. The Screen makes use of the Windows Console
|
||||
// API to display content and read events.
|
||||
//
|
||||
// Deprecated: The console API based implementation will be fully replaced
|
||||
// with the VT based model. Use NewScreen() to get a reasonable screen
|
||||
// by default.
|
||||
func NewConsoleScreen() (Screen, error) {
|
||||
return &baseScreen{screenImpl: &cScreen{}}, nil
|
||||
}
|
||||
@@ -217,22 +209,11 @@ func (s *cScreen) Init() error {
|
||||
|
||||
s.truecolor = true
|
||||
|
||||
// ConEmu handling of colors and scrolling when in VT output mode is extremely poor.
|
||||
// The color palette will scroll even though characters do not, when
|
||||
// emitting stuff for the last character. In the future we might change this to
|
||||
// look at specific versions of ConEmu if they fix the bug.
|
||||
// We can also try disabling auto margin mode.
|
||||
tryVt := true
|
||||
if os.Getenv("ConEmuPID") != "" {
|
||||
s.truecolor = false
|
||||
tryVt = false
|
||||
}
|
||||
switch os.Getenv("TCELL_TRUECOLOR") {
|
||||
case "disable":
|
||||
s.truecolor = false
|
||||
case "enable":
|
||||
s.truecolor = true
|
||||
tryVt = true
|
||||
}
|
||||
|
||||
s.Lock()
|
||||
@@ -249,33 +230,17 @@ func (s *cScreen) Init() error {
|
||||
s.fini = false
|
||||
s.setInMode(modeResizeEn | modeExtendFlg)
|
||||
|
||||
// If a user needs to force old style console, they may do so
|
||||
// by setting TCELL_VTMODE to disable. This is an undocumented safety net for now.
|
||||
// It may be removed in the future. (This mostly exists because of ConEmu.)
|
||||
switch os.Getenv("TCELL_VTMODE") {
|
||||
case "disable":
|
||||
tryVt = false
|
||||
case "enable":
|
||||
tryVt = true
|
||||
}
|
||||
switch os.Getenv("TCELL_ALTSCREEN") {
|
||||
case "enable":
|
||||
s.disableAlt = false // also the default
|
||||
case "disable":
|
||||
s.disableAlt = true
|
||||
}
|
||||
if tryVt {
|
||||
s.setOutMode(modeVtOutput | modeNoAutoNL | modeCookedOut | modeUnderline)
|
||||
var om uint32
|
||||
s.getOutMode(&om)
|
||||
if om&modeVtOutput == modeVtOutput {
|
||||
s.vten = true
|
||||
} else {
|
||||
s.truecolor = false
|
||||
s.setOutMode(0)
|
||||
}
|
||||
} else {
|
||||
s.setOutMode(0)
|
||||
s.setOutMode(modeVtOutput | modeNoAutoNL | modeCookedOut | modeUnderline)
|
||||
var om uint32
|
||||
s.getOutMode(&om)
|
||||
if om&modeVtOutput != modeVtOutput {
|
||||
return errors.New("failed to initialize: VT output not supported?")
|
||||
}
|
||||
|
||||
s.Unlock()
|
||||
@@ -349,17 +314,12 @@ func (s *cScreen) disengage() {
|
||||
|
||||
s.wg.Wait()
|
||||
|
||||
if s.vten {
|
||||
s.emitVtString(vtCursorStyles[CursorStyleDefault])
|
||||
s.emitVtString(vtCursorColorReset)
|
||||
s.emitVtString(vtEnableAm)
|
||||
if !s.disableAlt {
|
||||
s.emitVtString(vtRestoreTitle)
|
||||
s.emitVtString(vtExitCA)
|
||||
}
|
||||
} else if !s.disableAlt {
|
||||
s.clearScreen(StyleDefault, s.vten)
|
||||
s.setCursorPos(0, 0, false)
|
||||
s.emitVtString(vtCursorStyles[CursorStyleDefault])
|
||||
s.emitVtString(vtCursorColorReset)
|
||||
s.emitVtString(vtEnableAm)
|
||||
if !s.disableAlt {
|
||||
s.emitVtString(vtRestoreTitle)
|
||||
s.emitVtString(vtExitCA)
|
||||
}
|
||||
s.setCursorInfo(&s.ocursor)
|
||||
s.setBufferSize(int(s.oscreen.size.x), int(s.oscreen.size.y))
|
||||
@@ -388,22 +348,18 @@ func (s *cScreen) engage() error {
|
||||
s.running = true
|
||||
s.cancelflag = syscall.Handle(cf)
|
||||
s.enableMouse(s.mouseEnabled)
|
||||
|
||||
if s.vten {
|
||||
s.setOutMode(modeVtOutput | modeNoAutoNL | modeCookedOut | modeUnderline)
|
||||
if !s.disableAlt {
|
||||
s.emitVtString(vtSaveTitle)
|
||||
s.emitVtString(vtEnterCA)
|
||||
}
|
||||
s.emitVtString(vtDisableAm)
|
||||
if s.title != "" {
|
||||
s.emitVtString(fmt.Sprintf(vtSetTitle, s.title))
|
||||
}
|
||||
} else {
|
||||
s.setOutMode(0)
|
||||
s.setInMode(modeVtInput | modeResizeEn | modeExtendFlg)
|
||||
s.setOutMode(modeVtOutput | modeNoAutoNL | modeCookedOut | modeUnderline)
|
||||
if !s.disableAlt {
|
||||
s.emitVtString(vtSaveTitle)
|
||||
s.emitVtString(vtEnterCA)
|
||||
}
|
||||
s.emitVtString(vtDisableAm)
|
||||
if s.title != "" {
|
||||
s.emitVtString(fmt.Sprintf(vtSetTitle, s.title))
|
||||
}
|
||||
|
||||
s.clearScreen(s.style, s.vten)
|
||||
s.clearScreen(s.style)
|
||||
s.hideCursor()
|
||||
|
||||
s.cells.Invalidate()
|
||||
@@ -445,26 +401,18 @@ func (s *cScreen) emitVtString(vs string) {
|
||||
}
|
||||
|
||||
func (s *cScreen) showCursor() {
|
||||
if s.vten {
|
||||
s.emitVtString(vtShowCursor)
|
||||
s.emitVtString(vtCursorStyles[s.cursorStyle])
|
||||
if s.cursorColor == ColorReset {
|
||||
s.emitVtString(vtCursorColorReset)
|
||||
} else if s.cursorColor.Valid() {
|
||||
r, g, b := s.cursorColor.RGB()
|
||||
s.emitVtString(fmt.Sprintf(vtCursorColorRGB, r, g, b))
|
||||
}
|
||||
} else {
|
||||
s.setCursorInfo(&cursorInfo{size: 100, visible: 1})
|
||||
s.emitVtString(vtShowCursor)
|
||||
s.emitVtString(vtCursorStyles[s.cursorStyle])
|
||||
if s.cursorColor == ColorReset {
|
||||
s.emitVtString(vtCursorColorReset)
|
||||
} else if s.cursorColor.Valid() {
|
||||
r, g, b := s.cursorColor.RGB()
|
||||
s.emitVtString(fmt.Sprintf(vtCursorColorRGB, r, g, b))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cScreen) hideCursor() {
|
||||
if s.vten {
|
||||
s.emitVtString(vtHideCursor)
|
||||
} else {
|
||||
s.setCursorInfo(&cursorInfo{size: 1, visible: 0})
|
||||
}
|
||||
s.emitVtString(vtHideCursor)
|
||||
}
|
||||
|
||||
func (s *cScreen) ShowCursor(x, y int) {
|
||||
@@ -495,7 +443,7 @@ func (s *cScreen) doCursor() {
|
||||
if x < 0 || y < 0 || x >= s.w || y >= s.h {
|
||||
s.hideCursor()
|
||||
} else {
|
||||
s.setCursorPos(x, y, s.vten)
|
||||
s.setCursorPos(x, y)
|
||||
s.showCursor()
|
||||
}
|
||||
}
|
||||
@@ -504,20 +452,6 @@ func (s *cScreen) HideCursor() {
|
||||
s.ShowCursor(-1, -1)
|
||||
}
|
||||
|
||||
type inputRecord struct {
|
||||
typ uint16
|
||||
_ uint16
|
||||
data [16]byte
|
||||
}
|
||||
|
||||
const (
|
||||
keyEvent uint16 = 1
|
||||
mouseEvent uint16 = 2
|
||||
resizeEvent uint16 = 4
|
||||
menuEvent uint16 = 8 // don't use
|
||||
focusEvent uint16 = 16
|
||||
)
|
||||
|
||||
type mouseRecord struct {
|
||||
x int16
|
||||
y int16
|
||||
@@ -655,25 +589,28 @@ var vkKeys = map[uint16]Key{
|
||||
func getu32(v []byte) uint32 {
|
||||
return uint32(v[0]) + (uint32(v[1]) << 8) + (uint32(v[2]) << 16) + (uint32(v[3]) << 24)
|
||||
}
|
||||
|
||||
func geti32(v []byte) int32 {
|
||||
return int32(getu32(v))
|
||||
}
|
||||
|
||||
func getu16(v []byte) uint16 {
|
||||
return uint16(v[0]) + (uint16(v[1]) << 8)
|
||||
}
|
||||
|
||||
func geti16(v []byte) int16 {
|
||||
return int16(getu16(v))
|
||||
}
|
||||
|
||||
// Convert windows dwControlKeyState to modifier mask
|
||||
func mod2mask(cks uint32) ModMask {
|
||||
func mod2mask(cks uint32, filter_ctrl_alt bool) ModMask {
|
||||
mm := ModNone
|
||||
// Left or right control
|
||||
ctrl := (cks & (0x0008 | 0x0004)) != 0
|
||||
// Left or right alt
|
||||
alt := (cks & (0x0002 | 0x0001)) != 0
|
||||
// Filter out ctrl+alt (it means AltGr)
|
||||
if !(ctrl && alt) {
|
||||
if !filter_ctrl_alt || !(ctrl && alt) {
|
||||
if ctrl {
|
||||
mm |= ModCtrl
|
||||
}
|
||||
@@ -787,11 +724,15 @@ func (s *cScreen) getConsoleInput() error {
|
||||
if krec.ch != 0 {
|
||||
// synthesized key code
|
||||
for krec.repeat > 0 {
|
||||
if krec.ch < ' ' && mod2mask(krec.mod, false) == ModCtrl {
|
||||
krec.ch += '\x60'
|
||||
}
|
||||
|
||||
// convert shift+tab to backtab
|
||||
if mod2mask(krec.mod) == ModShift && krec.ch == vkTab {
|
||||
if mod2mask(krec.mod, false) == ModShift && krec.ch == vkTab {
|
||||
s.postEvent(NewEventKey(KeyBacktab, 0, ModNone))
|
||||
} else {
|
||||
s.postEvent(NewEventKey(KeyRune, rune(krec.ch), mod2mask(krec.mod)))
|
||||
s.postEvent(NewEventKey(KeyRune, rune(krec.ch), mod2mask(krec.mod, true)))
|
||||
}
|
||||
krec.repeat--
|
||||
}
|
||||
@@ -803,7 +744,7 @@ func (s *cScreen) getConsoleInput() error {
|
||||
return nil
|
||||
}
|
||||
for krec.repeat > 0 {
|
||||
s.postEvent(NewEventKey(key, rune(krec.ch), mod2mask(krec.mod)))
|
||||
s.postEvent(NewEventKey(key, rune(krec.ch), mod2mask(krec.mod, false)))
|
||||
krec.repeat--
|
||||
}
|
||||
|
||||
@@ -816,7 +757,7 @@ func (s *cScreen) getConsoleInput() error {
|
||||
mrec.flags = getu32(rec.data[12:])
|
||||
btns := mrec2btns(mrec.btns, mrec.flags)
|
||||
// we ignore double click, events are delivered normally
|
||||
s.postEvent(NewEventMouse(int(mrec.x), int(mrec.y), btns, mod2mask(mrec.mod)))
|
||||
s.postEvent(NewEventMouse(int(mrec.x), int(mrec.y), btns, mod2mask(mrec.mod, false)))
|
||||
|
||||
case resizeEvent:
|
||||
var rrec resizeRecord
|
||||
@@ -858,11 +799,10 @@ func (s *cScreen) scanInput(stopQ chan struct{}) {
|
||||
}
|
||||
|
||||
func (s *cScreen) Colors() int {
|
||||
if s.vten {
|
||||
return 1 << 24
|
||||
if !s.truecolor {
|
||||
return 16
|
||||
}
|
||||
// Windows console can display 8 colors, in either low or high intensity
|
||||
return 16
|
||||
return 1 << 24
|
||||
}
|
||||
|
||||
var vgaColors = map[Color]uint16{
|
||||
@@ -938,7 +878,7 @@ func (s *cScreen) mapStyle(style Style) uint16 {
|
||||
return attr
|
||||
}
|
||||
|
||||
func (s *cScreen) sendVtStyle(style Style) {
|
||||
func (s *cScreen) makeVtStyle(style Style) string {
|
||||
esc := &strings.Builder{}
|
||||
|
||||
fg, bg, attrs := style.fg, style.bg, style.attrs
|
||||
@@ -998,30 +938,32 @@ func (s *cScreen) sendVtStyle(style Style) {
|
||||
esc.WriteString(vtExitUrl)
|
||||
}
|
||||
|
||||
s.emitVtString(esc.String())
|
||||
return esc.String()
|
||||
}
|
||||
|
||||
func (s *cScreen) writeString(x, y int, style Style, ch []uint16) {
|
||||
func (s *cScreen) sendVtStyle(style Style) {
|
||||
s.emitVtString(s.makeVtStyle(style))
|
||||
}
|
||||
|
||||
func (s *cScreen) writeString(x, y int, style Style, vtBuf, ch []uint16) {
|
||||
// we assume the caller has hidden the cursor
|
||||
if len(ch) == 0 {
|
||||
return
|
||||
}
|
||||
s.setCursorPos(x, y, s.vten)
|
||||
|
||||
if s.vten {
|
||||
s.sendVtStyle(style)
|
||||
} else {
|
||||
_, _, _ = procSetConsoleTextAttribute.Call(
|
||||
uintptr(s.out),
|
||||
uintptr(s.mapStyle(style)))
|
||||
}
|
||||
_ = syscall.WriteConsole(s.out, &ch[0], uint32(len(ch)), nil, nil)
|
||||
vtBuf = append(vtBuf, utf16.Encode([]rune(fmt.Sprintf(vtCursorPos, y+1, x+1)))...)
|
||||
styleStr := s.makeVtStyle(style)
|
||||
vtBuf = append(vtBuf, utf16.Encode([]rune(styleStr))...)
|
||||
vtBuf = append(vtBuf, ch...)
|
||||
_ = syscall.WriteConsole(s.out, &vtBuf[0], uint32(len(vtBuf)), nil, nil)
|
||||
vtBuf = vtBuf[:0]
|
||||
}
|
||||
|
||||
func (s *cScreen) draw() {
|
||||
// allocate a scratch line bit enough for no combining chars.
|
||||
// if you have combining characters, you may pay for extra allocations.
|
||||
buf := make([]uint16, 0, s.w)
|
||||
var vtBuf []uint16
|
||||
wcs := buf[:]
|
||||
lstyle := styleInvalid
|
||||
|
||||
@@ -1040,7 +982,7 @@ func (s *cScreen) draw() {
|
||||
// write out any data queued thus far
|
||||
// because we are going to skip over some
|
||||
// cells, or because we need to change styles
|
||||
s.writeString(lx, ly, lstyle, wcs)
|
||||
s.writeString(lx, ly, lstyle, vtBuf, wcs)
|
||||
wcs = buf[0:0]
|
||||
lstyle = StyleDefault
|
||||
if !dirty {
|
||||
@@ -1067,7 +1009,7 @@ func (s *cScreen) draw() {
|
||||
}
|
||||
x += width - 1
|
||||
}
|
||||
s.writeString(lx, ly, lstyle, wcs)
|
||||
s.writeString(lx, ly, lstyle, vtBuf, wcs)
|
||||
wcs = buf[0:0]
|
||||
lstyle = styleInvalid
|
||||
}
|
||||
@@ -1122,15 +1064,9 @@ func (s *cScreen) setCursorInfo(info *cursorInfo) {
|
||||
uintptr(unsafe.Pointer(info)))
|
||||
}
|
||||
|
||||
func (s *cScreen) setCursorPos(x, y int, vtEnable bool) {
|
||||
if vtEnable {
|
||||
// Note that the string is Y first. Origin is 1,1.
|
||||
s.emitVtString(fmt.Sprintf(vtCursorPos, y+1, x+1))
|
||||
} else {
|
||||
_, _, _ = procSetConsoleCursorPosition.Call(
|
||||
uintptr(s.out),
|
||||
coord{int16(x), int16(y)}.uintptr())
|
||||
}
|
||||
func (s *cScreen) setCursorPos(x, y int) {
|
||||
// Note that the string is Y first. Origin is 1,1.
|
||||
s.emitVtString(fmt.Sprintf(vtCursorPos, y+1, x+1))
|
||||
}
|
||||
|
||||
func (s *cScreen) setBufferSize(x, y int) {
|
||||
@@ -1206,52 +1142,30 @@ func (s *cScreen) resize() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cScreen) clearScreen(style Style, vtEnable bool) {
|
||||
if vtEnable {
|
||||
s.sendVtStyle(style)
|
||||
row := strings.Repeat(" ", s.w)
|
||||
for y := 0; y < s.h; y++ {
|
||||
s.setCursorPos(0, y, vtEnable)
|
||||
s.emitVtString(row)
|
||||
}
|
||||
s.setCursorPos(0, 0, vtEnable)
|
||||
|
||||
} else {
|
||||
pos := coord{0, 0}
|
||||
attr := s.mapStyle(style)
|
||||
x, y := s.w, s.h
|
||||
scratch := uint32(0)
|
||||
count := uint32(x * y)
|
||||
|
||||
_, _, _ = procFillConsoleOutputAttribute.Call(
|
||||
uintptr(s.out),
|
||||
uintptr(attr),
|
||||
uintptr(count),
|
||||
pos.uintptr(),
|
||||
uintptr(unsafe.Pointer(&scratch)))
|
||||
_, _, _ = procFillConsoleOutputCharacter.Call(
|
||||
uintptr(s.out),
|
||||
uintptr(' '),
|
||||
uintptr(count),
|
||||
pos.uintptr(),
|
||||
uintptr(unsafe.Pointer(&scratch)))
|
||||
func (s *cScreen) clearScreen(style Style) {
|
||||
s.sendVtStyle(style)
|
||||
row := strings.Repeat(" ", s.w)
|
||||
for y := 0; y < s.h; y++ {
|
||||
s.setCursorPos(0, y)
|
||||
s.emitVtString(row)
|
||||
}
|
||||
s.setCursorPos(0, 0)
|
||||
}
|
||||
|
||||
const (
|
||||
// Input modes
|
||||
modeExtendFlg uint32 = 0x0080
|
||||
modeMouseEn = 0x0010
|
||||
modeResizeEn = 0x0008
|
||||
// modeCooked = 0x0001
|
||||
// modeVtInput = 0x0200
|
||||
modeExtendFlg = uint32(0x0080)
|
||||
modeMouseEn = uint32(0x0010)
|
||||
modeResizeEn = uint32(0x0008)
|
||||
modeVtInput = uint32(0x0200)
|
||||
// modeCooked = uint32(0x0001)
|
||||
|
||||
// Output modes
|
||||
modeCookedOut uint32 = 0x0001
|
||||
modeVtOutput = 0x0004
|
||||
modeNoAutoNL = 0x0008
|
||||
modeUnderline = 0x0010 // ENABLE_LVB_GRID_WORLDWIDE, needed for underlines
|
||||
// modeWrapEOL = 0x0002
|
||||
modeCookedOut = uint32(0x0001)
|
||||
modeVtOutput = uint32(0x0004)
|
||||
modeNoAutoNL = uint32(0x0008)
|
||||
modeUnderline = uint32(0x0010) // ENABLE_LVB_GRID_WORLDWIDE, needed for underlines
|
||||
// modeWrapEOL = uint32(0x0002)
|
||||
)
|
||||
|
||||
func (s *cScreen) setInMode(mode uint32) {
|
||||
@@ -1287,9 +1201,7 @@ func (s *cScreen) SetStyle(style Style) {
|
||||
func (s *cScreen) SetTitle(title string) {
|
||||
s.Lock()
|
||||
s.title = title
|
||||
if s.vten {
|
||||
s.emitVtString(fmt.Sprintf(vtSetTitle, title))
|
||||
}
|
||||
s.emitVtString(fmt.Sprintf(vtSetTitle, title))
|
||||
s.Unlock()
|
||||
}
|
||||
|
||||
@@ -1320,43 +1232,8 @@ func (s *cScreen) GetClipboard() {
|
||||
|
||||
func (s *cScreen) Resize(int, int, int, int) {}
|
||||
|
||||
func (s *cScreen) HasKey(k Key) bool {
|
||||
// Microsoft has codes for some keys, but they are unusual,
|
||||
// so we don't include them. We include all the typical
|
||||
// 101, 105 key layout keys.
|
||||
valid := map[Key]bool{
|
||||
KeyBackspace: true,
|
||||
KeyTab: true,
|
||||
KeyEscape: true,
|
||||
KeyPause: true,
|
||||
KeyPrint: true,
|
||||
KeyPgUp: true,
|
||||
KeyPgDn: true,
|
||||
KeyEnter: true,
|
||||
KeyEnd: true,
|
||||
KeyHome: true,
|
||||
KeyLeft: true,
|
||||
KeyUp: true,
|
||||
KeyRight: true,
|
||||
KeyDown: true,
|
||||
KeyInsert: true,
|
||||
KeyDelete: true,
|
||||
KeyF1: true,
|
||||
KeyF2: true,
|
||||
KeyF3: true,
|
||||
KeyF4: true,
|
||||
KeyF5: true,
|
||||
KeyF6: true,
|
||||
KeyF7: true,
|
||||
KeyF8: true,
|
||||
KeyF9: true,
|
||||
KeyF10: true,
|
||||
KeyF11: true,
|
||||
KeyF12: true,
|
||||
KeyRune: true,
|
||||
}
|
||||
|
||||
return valid[k]
|
||||
func (s *cScreen) HasKey(_ Key) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *cScreen) Beep() error {
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright 2025 The TCell Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package tcell
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/rivo/uniseg"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if rw := strings.ToLower(os.Getenv("RUNEWIDTH_EASTASIAN")); rw == "1" || rw == "true" || rw == "yes" {
|
||||
uniseg.EastAsianAmbiguousWidth = 2
|
||||
} else {
|
||||
uniseg.EastAsianAmbiguousWidth = 1
|
||||
}
|
||||
}
|
||||
+944
@@ -0,0 +1,944 @@
|
||||
// Copyright 2025 The TCell Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// This file describes a generic VT input processor. It parses key sequences,
|
||||
// (input bytes) and loads them into events. It expects UTF-8 or UTF-16 as the input
|
||||
// feed, along with ECMA-48 sequences. The assumption here is that all potential
|
||||
// key sequences are unambiguous between terminal variants (analysis of extant terminfo
|
||||
// data appears to support this conjecture). This allows us to implement this once,
|
||||
// in the most efficient and terminal-agnostic way possible.
|
||||
//
|
||||
// There is unfortunately *one* conflict, with aixterm, for CSI-P - which is KeyDelete
|
||||
// in aixterm, but F1 in others.
|
||||
|
||||
package tcell
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type inpState int
|
||||
|
||||
const (
|
||||
inpStateInit = inpState(iota)
|
||||
inpStateUtf
|
||||
inpStateEsc
|
||||
inpStateCsi // control sequence introducer
|
||||
inpStateOsc // operating system command
|
||||
inpStateDcs // device control string
|
||||
inpStateSos // start of string (unused)
|
||||
inpStatePm // privacy message (unused)
|
||||
inpStateApc // application program command
|
||||
inpStateSt // string terminator
|
||||
inpStateSs2 // single shift 2
|
||||
inpStateSs3 // single shift 3
|
||||
inpStateLFK // linux F-key (not ECMA-48 compliant - bogus CSI)
|
||||
)
|
||||
|
||||
type InputProcessor interface {
|
||||
ScanUTF8([]byte)
|
||||
ScanUTF16([]uint16)
|
||||
SetSize(rows, cols int)
|
||||
}
|
||||
|
||||
func NewInputProcessor(eq chan<- Event) InputProcessor {
|
||||
return &inputProcessor{
|
||||
evch: eq,
|
||||
buf: make([]rune, 0, 128),
|
||||
}
|
||||
}
|
||||
|
||||
type inputProcessor struct {
|
||||
ut8 []byte
|
||||
ut16 []uint16
|
||||
buf []rune
|
||||
scratch []byte
|
||||
csiParams []byte
|
||||
csiInterm []byte
|
||||
escaped bool
|
||||
btnDown bool // mouse button tracking for broken terms
|
||||
state inpState
|
||||
strState inpState // saved str state (needed for ST)
|
||||
timer *time.Timer
|
||||
expire time.Time
|
||||
l sync.Mutex
|
||||
encBuf []rune
|
||||
evch chan<- Event
|
||||
rows int // used for clipping mouse coordinates
|
||||
cols int // used for clipping mouse coordinates
|
||||
surrogate rune
|
||||
nested *inputProcessor
|
||||
}
|
||||
|
||||
func (ip *inputProcessor) SetSize(w, h int) {
|
||||
if ip.nested != nil {
|
||||
ip.nested.SetSize(w, h)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ip.l.Lock()
|
||||
ip.rows = h
|
||||
ip.cols = w
|
||||
ip.post(NewEventResize(w, h))
|
||||
ip.l.Unlock()
|
||||
}()
|
||||
}
|
||||
func (ip *inputProcessor) post(ev Event) {
|
||||
if ip.escaped {
|
||||
ip.escaped = false
|
||||
if ke, ok := ev.(*EventKey); ok {
|
||||
ev = NewEventKey(ke.Key(), ke.Rune(), ke.Modifiers()|ModAlt)
|
||||
}
|
||||
} else if ke, ok := ev.(*EventKey); ok {
|
||||
switch ke.Key() {
|
||||
case keyPasteStart:
|
||||
ev = NewEventPaste(true)
|
||||
case keyPasteEnd:
|
||||
ev = NewEventPaste(false)
|
||||
}
|
||||
}
|
||||
|
||||
ip.evch <- ev
|
||||
}
|
||||
|
||||
func (ip *inputProcessor) escTimeout() {
|
||||
ip.l.Lock()
|
||||
defer ip.l.Unlock()
|
||||
if ip.state == inpStateEsc && ip.expire.Before(time.Now()) {
|
||||
// post it
|
||||
ip.state = inpStateInit
|
||||
ip.escaped = false
|
||||
ip.post(NewEventKey(KeyEsc, 0, ModNone))
|
||||
}
|
||||
}
|
||||
|
||||
type csiParamMode struct {
|
||||
M rune // Mode
|
||||
P int // Parameter (first)
|
||||
}
|
||||
|
||||
type keyMap struct {
|
||||
Key Key
|
||||
Mod ModMask
|
||||
Rune rune
|
||||
}
|
||||
|
||||
var csiAllKeys = map[csiParamMode]keyMap{
|
||||
{M: 'A'}: {Key: KeyUp},
|
||||
{M: 'B'}: {Key: KeyDown},
|
||||
{M: 'C'}: {Key: KeyRight},
|
||||
{M: 'D'}: {Key: KeyLeft},
|
||||
{M: 'F'}: {Key: KeyEnd},
|
||||
{M: 'H'}: {Key: KeyHome},
|
||||
{M: 'L'}: {Key: KeyInsert},
|
||||
{M: 'P'}: {Key: KeyF1}, // except for aixterm, where this is Delete
|
||||
{M: 'Q'}: {Key: KeyF2},
|
||||
{M: 'S'}: {Key: KeyF4},
|
||||
{M: 'Z'}: {Key: KeyBacktab},
|
||||
{M: 'a'}: {Key: KeyUp, Mod: ModShift},
|
||||
{M: 'b'}: {Key: KeyDown, Mod: ModShift},
|
||||
{M: 'c'}: {Key: KeyRight, Mod: ModShift},
|
||||
{M: 'd'}: {Key: KeyLeft, Mod: ModShift},
|
||||
{M: 'q', P: 1}: {Key: KeyF1}, // all these 'q' are for aixterm
|
||||
{M: 'q', P: 2}: {Key: KeyF2},
|
||||
{M: 'q', P: 3}: {Key: KeyF3},
|
||||
{M: 'q', P: 4}: {Key: KeyF4},
|
||||
{M: 'q', P: 5}: {Key: KeyF5},
|
||||
{M: 'q', P: 6}: {Key: KeyF6},
|
||||
{M: 'q', P: 7}: {Key: KeyF7},
|
||||
{M: 'q', P: 8}: {Key: KeyF8},
|
||||
{M: 'q', P: 9}: {Key: KeyF9},
|
||||
{M: 'q', P: 10}: {Key: KeyF10},
|
||||
{M: 'q', P: 11}: {Key: KeyF11},
|
||||
{M: 'q', P: 12}: {Key: KeyF12},
|
||||
{M: 'q', P: 13}: {Key: KeyF13},
|
||||
{M: 'q', P: 14}: {Key: KeyF14},
|
||||
{M: 'q', P: 15}: {Key: KeyF15},
|
||||
{M: 'q', P: 16}: {Key: KeyF16},
|
||||
{M: 'q', P: 17}: {Key: KeyF17},
|
||||
{M: 'q', P: 18}: {Key: KeyF18},
|
||||
{M: 'q', P: 19}: {Key: KeyF19},
|
||||
{M: 'q', P: 20}: {Key: KeyF20},
|
||||
{M: 'q', P: 21}: {Key: KeyF21},
|
||||
{M: 'q', P: 22}: {Key: KeyF22},
|
||||
{M: 'q', P: 23}: {Key: KeyF23},
|
||||
{M: 'q', P: 24}: {Key: KeyF24},
|
||||
{M: 'q', P: 25}: {Key: KeyF25},
|
||||
{M: 'q', P: 26}: {Key: KeyF26},
|
||||
{M: 'q', P: 27}: {Key: KeyF27},
|
||||
{M: 'q', P: 28}: {Key: KeyF28},
|
||||
{M: 'q', P: 29}: {Key: KeyF29},
|
||||
{M: 'q', P: 30}: {Key: KeyF30},
|
||||
{M: 'q', P: 31}: {Key: KeyF31},
|
||||
{M: 'q', P: 32}: {Key: KeyF32},
|
||||
{M: 'q', P: 33}: {Key: KeyF33},
|
||||
{M: 'q', P: 34}: {Key: KeyF34},
|
||||
{M: 'q', P: 35}: {Key: KeyF35},
|
||||
{M: 'q', P: 36}: {Key: KeyF36},
|
||||
{M: 'q', P: 144}: {Key: KeyClear},
|
||||
{M: 'q', P: 146}: {Key: KeyEnd},
|
||||
{M: 'q', P: 150}: {Key: KeyPgUp},
|
||||
{M: 'q', P: 154}: {Key: KeyPgDn},
|
||||
{M: 'z', P: 214}: {Key: KeyHome},
|
||||
{M: 'z', P: 216}: {Key: KeyPgUp},
|
||||
{M: 'z', P: 220}: {Key: KeyEnd},
|
||||
{M: 'z', P: 222}: {Key: KeyPgDn},
|
||||
{M: 'z', P: 224}: {Key: KeyF1},
|
||||
{M: 'z', P: 225}: {Key: KeyF2},
|
||||
{M: 'z', P: 226}: {Key: KeyF3},
|
||||
{M: 'z', P: 227}: {Key: KeyF4},
|
||||
{M: 'z', P: 228}: {Key: KeyF5},
|
||||
{M: 'z', P: 229}: {Key: KeyF6},
|
||||
{M: 'z', P: 230}: {Key: KeyF7},
|
||||
{M: 'z', P: 231}: {Key: KeyF8},
|
||||
{M: 'z', P: 232}: {Key: KeyF9},
|
||||
{M: 'z', P: 233}: {Key: KeyF10},
|
||||
{M: 'z', P: 234}: {Key: KeyF11},
|
||||
{M: 'z', P: 235}: {Key: KeyF12},
|
||||
{M: 'z', P: 247}: {Key: KeyInsert},
|
||||
{M: '^', P: 7}: {Key: KeyHome, Mod: ModCtrl},
|
||||
{M: '^', P: 8}: {Key: KeyEnd, Mod: ModCtrl},
|
||||
{M: '^', P: 11}: {Key: KeyF23},
|
||||
{M: '^', P: 12}: {Key: KeyF24},
|
||||
{M: '^', P: 13}: {Key: KeyF25},
|
||||
{M: '^', P: 14}: {Key: KeyF26},
|
||||
{M: '^', P: 15}: {Key: KeyF27},
|
||||
{M: '^', P: 17}: {Key: KeyF28}, // 16 is a gap
|
||||
{M: '^', P: 18}: {Key: KeyF29},
|
||||
{M: '^', P: 19}: {Key: KeyF30},
|
||||
{M: '^', P: 20}: {Key: KeyF31},
|
||||
{M: '^', P: 21}: {Key: KeyF32},
|
||||
{M: '^', P: 23}: {Key: KeyF33}, // 22 is a gap
|
||||
{M: '^', P: 24}: {Key: KeyF34},
|
||||
{M: '^', P: 25}: {Key: KeyF35},
|
||||
{M: '^', P: 26}: {Key: KeyF36}, // 27 is a gap
|
||||
{M: '^', P: 28}: {Key: KeyF37},
|
||||
{M: '^', P: 29}: {Key: KeyF38}, // 30 is a gap
|
||||
{M: '^', P: 31}: {Key: KeyF39},
|
||||
{M: '^', P: 32}: {Key: KeyF40},
|
||||
{M: '^', P: 33}: {Key: KeyF41},
|
||||
{M: '^', P: 34}: {Key: KeyF42},
|
||||
{M: '@', P: 23}: {Key: KeyF43},
|
||||
{M: '@', P: 24}: {Key: KeyF44},
|
||||
{M: '$', P: 2}: {Key: KeyInsert, Mod: ModShift},
|
||||
{M: '$', P: 3}: {Key: KeyDelete, Mod: ModShift},
|
||||
{M: '$', P: 7}: {Key: KeyHome, Mod: ModShift},
|
||||
{M: '$', P: 8}: {Key: KeyEnd, Mod: ModShift},
|
||||
{M: '$', P: 23}: {Key: KeyF21},
|
||||
{M: '$', P: 24}: {Key: KeyF22},
|
||||
{M: '~', P: 1}: {Key: KeyHome},
|
||||
{M: '~', P: 2}: {Key: KeyInsert},
|
||||
{M: '~', P: 3}: {Key: KeyDelete},
|
||||
{M: '~', P: 4}: {Key: KeyEnd},
|
||||
{M: '~', P: 5}: {Key: KeyPgUp},
|
||||
{M: '~', P: 6}: {Key: KeyPgDn},
|
||||
{M: '~', P: 7}: {Key: KeyHome},
|
||||
{M: '~', P: 8}: {Key: KeyEnd},
|
||||
{M: '~', P: 11}: {Key: KeyF1},
|
||||
{M: '~', P: 12}: {Key: KeyF2},
|
||||
{M: '~', P: 13}: {Key: KeyF3},
|
||||
{M: '~', P: 14}: {Key: KeyF4},
|
||||
{M: '~', P: 15}: {Key: KeyF5},
|
||||
{M: '~', P: 17}: {Key: KeyF6},
|
||||
{M: '~', P: 18}: {Key: KeyF7},
|
||||
{M: '~', P: 19}: {Key: KeyF8},
|
||||
{M: '~', P: 20}: {Key: KeyF9},
|
||||
{M: '~', P: 21}: {Key: KeyF10},
|
||||
{M: '~', P: 23}: {Key: KeyF11},
|
||||
{M: '~', P: 24}: {Key: KeyF12},
|
||||
{M: '~', P: 25}: {Key: KeyF13},
|
||||
{M: '~', P: 26}: {Key: KeyF14},
|
||||
{M: '~', P: 28}: {Key: KeyF15}, // aka KeyHelp
|
||||
{M: '~', P: 29}: {Key: KeyF16},
|
||||
{M: '~', P: 31}: {Key: KeyF17},
|
||||
{M: '~', P: 32}: {Key: KeyF18},
|
||||
{M: '~', P: 33}: {Key: KeyF19},
|
||||
{M: '~', P: 34}: {Key: KeyF20},
|
||||
{M: '~', P: 200}: {Key: keyPasteStart},
|
||||
{M: '~', P: 201}: {Key: keyPasteEnd},
|
||||
}
|
||||
|
||||
// keys reported using Kitty csi-u protocol
|
||||
var csiUKeys = map[int]keyMap{
|
||||
27: {Key: KeyESC},
|
||||
9: {Key: KeyTAB},
|
||||
13: {Key: KeyEnter},
|
||||
127: {Key: KeyBS},
|
||||
57358: {Key: KeyCapsLock},
|
||||
57359: {Key: KeyScrollLock},
|
||||
57360: {Key: KeyNumLock},
|
||||
57361: {Key: KeyPrint},
|
||||
57362: {Key: KeyPause},
|
||||
57363: {Key: KeyMenu},
|
||||
57376: {Key: KeyF13},
|
||||
57377: {Key: KeyF14},
|
||||
57378: {Key: KeyF15},
|
||||
57379: {Key: KeyF16},
|
||||
57380: {Key: KeyF17},
|
||||
57381: {Key: KeyF18},
|
||||
57382: {Key: KeyF19},
|
||||
57383: {Key: KeyF20},
|
||||
57384: {Key: KeyF21},
|
||||
57385: {Key: KeyF22},
|
||||
57386: {Key: KeyF23},
|
||||
57387: {Key: KeyF24},
|
||||
57388: {Key: KeyF25},
|
||||
57389: {Key: KeyF26},
|
||||
57390: {Key: KeyF27},
|
||||
57391: {Key: KeyF28},
|
||||
57392: {Key: KeyF29},
|
||||
57393: {Key: KeyF30},
|
||||
57394: {Key: KeyF31},
|
||||
57395: {Key: KeyF32},
|
||||
57396: {Key: KeyF33},
|
||||
57397: {Key: KeyF34},
|
||||
57398: {Key: KeyF35},
|
||||
57399: {Key: KeyRune, Rune: '0'}, // KP 0
|
||||
57400: {Key: KeyRune, Rune: '1'}, // KP 1
|
||||
57401: {Key: KeyRune, Rune: '2'}, // KP 2
|
||||
57402: {Key: KeyRune, Rune: '3'}, // KP 3
|
||||
57403: {Key: KeyRune, Rune: '4'}, // KP 4
|
||||
57404: {Key: KeyRune, Rune: '5'}, // KP 5
|
||||
57405: {Key: KeyRune, Rune: '6'}, // KP 6
|
||||
57406: {Key: KeyRune, Rune: '7'}, // KP 7
|
||||
57407: {Key: KeyRune, Rune: '8'}, // KP 8
|
||||
57408: {Key: KeyRune, Rune: '9'}, // KP 9
|
||||
57409: {Key: KeyRune, Rune: '.'}, // KP_DECIMAL
|
||||
57410: {Key: KeyRune, Rune: '/'}, // KP_DIVIDE
|
||||
57411: {Key: KeyRune, Rune: '*'}, // KP_MULTIPLY
|
||||
57412: {Key: KeyRune, Rune: '-'}, // KP_SUBTRACT
|
||||
57413: {Key: KeyRune, Rune: '+'}, // KP_ADD
|
||||
57414: {Key: KeyEnter}, // KP_ENTER
|
||||
57415: {Key: KeyRune, Rune: '='}, // KP_EQUAL
|
||||
57416: {Key: KeyClear}, // KP_SEPARATOR
|
||||
57417: {Key: KeyLeft}, // KP_LEFT
|
||||
57418: {Key: KeyRight}, // KP_RIGHT
|
||||
57419: {Key: KeyUp}, // KP_UP
|
||||
57420: {Key: KeyDown}, // KP_DOWN
|
||||
57421: {Key: KeyPgUp}, // KP_PG_UP
|
||||
57422: {Key: KeyPgDn}, // KP_PG_DN
|
||||
57423: {Key: KeyHome}, // KP_HOME
|
||||
57424: {Key: KeyEnd}, // KP_END
|
||||
57425: {Key: KeyInsert}, // KP_INSERT
|
||||
57426: {Key: KeyDelete}, // KP_DELETE
|
||||
// 57427: {Key: KeyBegin}, // KP_BEGIN
|
||||
|
||||
// TODO: Media keys
|
||||
}
|
||||
|
||||
// windows virtual key codes per microsoft
|
||||
var winKeys = map[int]Key{
|
||||
0x03: KeyCancel, // vkCancel
|
||||
0x08: KeyBackspace, // vkBackspace
|
||||
0x09: KeyTab, // vkTab
|
||||
0x0c: KeyClear, // vClear
|
||||
0x0d: KeyEnter, // vkReturn
|
||||
0x13: KeyPause, // vkPause
|
||||
0x1b: KeyEscape, // vkEscape
|
||||
0x21: KeyPgUp, // vkPrior
|
||||
0x22: KeyPgDn, // vkNext
|
||||
0x23: KeyEnd, // vkEnd
|
||||
0x24: KeyHome, // vkHome
|
||||
0x25: KeyLeft, // vkLeft
|
||||
0x26: KeyUp, // vkUp
|
||||
0x27: KeyRight, // vkRight
|
||||
0x28: KeyDown, // vkDown
|
||||
0x2a: KeyPrint, // vkPrint
|
||||
0x2c: KeyPrint, // vkPrtScr
|
||||
0x2d: KeyInsert, // vkInsert
|
||||
0x2e: KeyDelete, // vkDelete
|
||||
0x2f: KeyHelp, // vkHelp
|
||||
0x70: KeyF1, // vkF1
|
||||
0x71: KeyF2, // vkF2
|
||||
0x72: KeyF3, // vkF3
|
||||
0x73: KeyF4, // vkF4
|
||||
0x74: KeyF5, // vkF5
|
||||
0x75: KeyF6, // vkF6
|
||||
0x76: KeyF7, // vkF7
|
||||
0x77: KeyF8, // vkF8
|
||||
0x78: KeyF9, // vkF9
|
||||
0x79: KeyF10, // vkF10
|
||||
0x7a: KeyF11, // vkF11
|
||||
0x7b: KeyF12, // vkF12
|
||||
0x7c: KeyF13, // vkF13
|
||||
0x7d: KeyF14, // vkF14
|
||||
0x7e: KeyF15, // vkF15
|
||||
0x7f: KeyF16, // vkF16
|
||||
0x80: KeyF17, // vkF17
|
||||
0x81: KeyF18, // vkF18
|
||||
0x82: KeyF19, // vkF19
|
||||
0x83: KeyF20, // vkF20
|
||||
0x84: KeyF21, // vkF21
|
||||
0x85: KeyF22, // vkF22
|
||||
0x86: KeyF23, // vkF23
|
||||
0x87: KeyF24, // vkF24
|
||||
}
|
||||
|
||||
// keys by their SS3 - used in application mode usually (legacy VT-style)
|
||||
var ss3Keys = map[rune]Key{
|
||||
'A': KeyUp,
|
||||
'B': KeyDown,
|
||||
'C': KeyRight,
|
||||
'D': KeyLeft,
|
||||
'F': KeyEnd,
|
||||
'H': KeyHome,
|
||||
'P': KeyF1,
|
||||
'Q': KeyF2,
|
||||
'R': KeyF3,
|
||||
'S': KeyF4,
|
||||
't': KeyF5,
|
||||
'u': KeyF6,
|
||||
'v': KeyF7,
|
||||
'l': KeyF8,
|
||||
'w': KeyF9,
|
||||
'x': KeyF10,
|
||||
}
|
||||
|
||||
// linux terminal uses these non ECMA keys prefixed by CSI-[
|
||||
var linuxFKeys = map[rune]Key{
|
||||
'A': KeyF1,
|
||||
'B': KeyF2,
|
||||
'C': KeyF3,
|
||||
'D': KeyF4,
|
||||
'E': KeyF5,
|
||||
}
|
||||
|
||||
func (ip *inputProcessor) scan() {
|
||||
for _, r := range ip.buf {
|
||||
ip.buf = ip.buf[1:]
|
||||
if r > 0x7F {
|
||||
// 8-bit extended Unicode we just treat as such - this will swallow anything else queued up
|
||||
ip.state = inpStateInit
|
||||
ip.post(NewEventKey(KeyRune, r, ModNone))
|
||||
continue
|
||||
}
|
||||
switch ip.state {
|
||||
case inpStateInit:
|
||||
switch r {
|
||||
case '\x1b':
|
||||
// escape.. pending
|
||||
ip.state = inpStateEsc
|
||||
if len(ip.buf) == 0 && ip.nested == nil {
|
||||
ip.expire = time.Now().Add(time.Millisecond * 50)
|
||||
ip.timer = time.AfterFunc(time.Millisecond*60, ip.escTimeout)
|
||||
}
|
||||
case '\t':
|
||||
ip.post(NewEventKey(KeyTab, 0, ModNone))
|
||||
case '\b', '\x7F':
|
||||
ip.post(NewEventKey(KeyBackspace, 0, ModNone))
|
||||
case '\r':
|
||||
ip.post(NewEventKey(KeyEnter, 0, ModNone))
|
||||
default:
|
||||
// Control keys - legacy handling
|
||||
if r < ' ' {
|
||||
ip.post(NewEventKey(KeyCtrlSpace+Key(r), 0, ModCtrl))
|
||||
} else {
|
||||
ip.post(NewEventKey(KeyRune, r, ModNone))
|
||||
}
|
||||
}
|
||||
case inpStateEsc:
|
||||
switch r {
|
||||
case '[':
|
||||
ip.state = inpStateCsi
|
||||
ip.csiInterm = nil
|
||||
ip.csiParams = nil
|
||||
case ']':
|
||||
ip.state = inpStateOsc
|
||||
ip.scratch = nil
|
||||
case 'N':
|
||||
ip.state = inpStateSs2 // no known uses
|
||||
ip.scratch = nil
|
||||
case 'O':
|
||||
ip.state = inpStateSs3
|
||||
ip.scratch = nil
|
||||
case 'X':
|
||||
ip.state = inpStateSos
|
||||
ip.scratch = nil
|
||||
case '^':
|
||||
ip.state = inpStatePm
|
||||
ip.scratch = nil
|
||||
case '_':
|
||||
ip.state = inpStateApc
|
||||
ip.scratch = nil
|
||||
case '\\':
|
||||
// string terminator reached, (orphaned?)
|
||||
ip.state = inpStateInit
|
||||
case '\t':
|
||||
// Linux console only, does not conform to ECMA
|
||||
ip.state = inpStateInit
|
||||
ip.post(NewEventKey(KeyBacktab, 0, ModNone))
|
||||
default:
|
||||
if r == '\x1b' {
|
||||
// leading ESC to capture alt
|
||||
ip.escaped = true
|
||||
} else {
|
||||
// treat as alt-key ... legacy emulators only (no CSI-u or other)
|
||||
ip.state = inpStateInit
|
||||
mod := ModAlt
|
||||
if r < ' ' {
|
||||
mod |= ModCtrl
|
||||
r += 0x60
|
||||
}
|
||||
ip.post(NewEventKey(KeyRune, r, mod))
|
||||
}
|
||||
}
|
||||
case inpStateCsi:
|
||||
// usual case for incoming keys
|
||||
if r == '\x1b' {
|
||||
// Per ECMA-48 §5.3.1, ESC restarts the escape
|
||||
// sequence machine from any intermediate state.
|
||||
ip.state = inpStateEsc
|
||||
if len(ip.buf) == 0 && ip.nested == nil {
|
||||
ip.expire = time.Now().Add(time.Millisecond * 50)
|
||||
ip.timer = time.AfterFunc(time.Millisecond*60, ip.escTimeout)
|
||||
}
|
||||
} else if r >= 0x30 && r <= 0x3F { // parameter bytes
|
||||
ip.csiParams = append(ip.csiParams, byte(r))
|
||||
} else if r >= 0x20 && r <= 0x2F { // intermediate bytes, rarely used
|
||||
ip.csiInterm = append(ip.csiInterm, byte(r))
|
||||
} else if r >= 0x40 && r <= 0x7F { // final byte
|
||||
ip.handleCsi(r, ip.csiParams, ip.csiInterm)
|
||||
} else {
|
||||
// bad parse, just swallow it all
|
||||
ip.state = inpStateInit
|
||||
}
|
||||
case inpStateSs2:
|
||||
// No known uses for SS2
|
||||
ip.state = inpStateInit
|
||||
|
||||
case inpStateSs3: // typically application mode keys or older terminals
|
||||
ip.state = inpStateInit
|
||||
if r == '\x1b' {
|
||||
// Per ECMA-48 §5.3.1, ESC restarts the escape
|
||||
// sequence machine from any intermediate state.
|
||||
ip.state = inpStateEsc
|
||||
if len(ip.buf) == 0 && ip.nested == nil {
|
||||
ip.expire = time.Now().Add(time.Millisecond * 50)
|
||||
ip.timer = time.AfterFunc(time.Millisecond*60, ip.escTimeout)
|
||||
}
|
||||
} else if k, ok := ss3Keys[r]; ok {
|
||||
ip.post(NewEventKey(k, 0, ModNone))
|
||||
}
|
||||
|
||||
case inpStatePm, inpStateApc, inpStateSos, inpStateDcs: // these we just eat
|
||||
switch r {
|
||||
case '\x1b':
|
||||
ip.strState = ip.state
|
||||
ip.state = inpStateSt
|
||||
case '\x07': // bell - some send this instead of ST
|
||||
ip.state = inpStateInit
|
||||
}
|
||||
|
||||
case inpStateOsc: // not sure if used
|
||||
switch r {
|
||||
case '\x1b':
|
||||
ip.strState = ip.state
|
||||
ip.state = inpStateSt
|
||||
case '\x07':
|
||||
ip.handleOsc(string(ip.scratch))
|
||||
default:
|
||||
ip.scratch = append(ip.scratch, byte(r&0x7f))
|
||||
}
|
||||
case inpStateSt:
|
||||
if r == '\\' || r == '\x07' {
|
||||
ip.state = inpStateInit
|
||||
switch ip.strState {
|
||||
case inpStateOsc:
|
||||
ip.handleOsc(string(ip.scratch))
|
||||
case inpStatePm, inpStateApc, inpStateSos, inpStateDcs:
|
||||
ip.state = inpStateInit
|
||||
}
|
||||
} else {
|
||||
ip.scratch = append(ip.scratch, '\x1b', byte(r))
|
||||
ip.state = ip.strState
|
||||
}
|
||||
case inpStateLFK:
|
||||
// linux console does not follow ECMA
|
||||
if k, ok := linuxFKeys[r]; ok {
|
||||
ip.post(NewEventKey(k, 0, ModNone))
|
||||
}
|
||||
ip.state = inpStateInit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ip *inputProcessor) handleOsc(str string) {
|
||||
ip.state = inpStateInit
|
||||
if content, ok := strings.CutPrefix(str, "52;c;"); ok {
|
||||
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(content)))
|
||||
if count, err := base64.StdEncoding.Decode(decoded, []byte(content)); err == nil {
|
||||
ip.post(NewEventClipboard(decoded[:count]))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func calcModifier(n int) ModMask {
|
||||
n--
|
||||
m := ModNone
|
||||
if n&1 != 0 {
|
||||
m |= ModShift
|
||||
}
|
||||
if n&2 != 0 {
|
||||
m |= ModAlt
|
||||
}
|
||||
if n&4 != 0 {
|
||||
m |= ModCtrl
|
||||
}
|
||||
if n&8 != 0 {
|
||||
m |= ModMeta // kitty calls this Super
|
||||
}
|
||||
if n&16 != 0 {
|
||||
m |= ModHyper
|
||||
}
|
||||
if n&32 != 0 {
|
||||
m |= ModMeta // for now not separating from Super
|
||||
}
|
||||
// Not doing (kitty only):
|
||||
// caps_lock 0b1000000 (64)
|
||||
// num_lock 0b10000000 (128)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// func (ip *inputProcessor) handleMouse(x, y, btn int, down bool) *EventMouse {
|
||||
func (ip *inputProcessor) handleMouse(mode rune, params []int) {
|
||||
|
||||
// XTerm mouse events only report at most one button at a time,
|
||||
// which may include a wheel button. Wheel motion events are
|
||||
// reported as single impulses, while other button events are reported
|
||||
// as separate press & release events.
|
||||
if len(params) < 3 {
|
||||
return
|
||||
}
|
||||
btn := params[0]
|
||||
// Some terminals will report mouse coordinates outside the
|
||||
// screen, especially with click-drag events. Clip the coordinates
|
||||
// to the screen in that case.
|
||||
x := max(min(params[1]-1, ip.cols-1), 0)
|
||||
y := max(min(params[2]-1, ip.rows-1), 0)
|
||||
motion := (btn & 0x20) != 0
|
||||
scroll := (btn & 0x42) == 0x40
|
||||
btn &^= 0x20
|
||||
if mode == 'm' {
|
||||
// mouse release, clear all buttons
|
||||
btn |= 3
|
||||
btn &^= 0x40
|
||||
ip.btnDown = false
|
||||
} else if motion {
|
||||
/*
|
||||
* Some broken terminals appear to send
|
||||
* mouse button one motion events, instead of
|
||||
* encoding 35 (no buttons) into these events.
|
||||
* We resolve these by looking for a non-motion
|
||||
* event first.
|
||||
*/
|
||||
if !ip.btnDown {
|
||||
btn |= 3
|
||||
btn &^= 0x40
|
||||
}
|
||||
} else if !scroll {
|
||||
ip.btnDown = true
|
||||
}
|
||||
|
||||
button := ButtonNone
|
||||
mod := ModNone
|
||||
|
||||
// Mouse wheel has bit 6 set, no release events. It should be noted
|
||||
// that wheel events are sometimes misdelivered as mouse button events
|
||||
// during a click-drag, so we debounce these, considering them to be
|
||||
// button press events unless we see an intervening release event.
|
||||
switch btn & 0x43 {
|
||||
case 0:
|
||||
button = Button1
|
||||
case 1:
|
||||
button = Button3 // Note we prefer to treat right as button 2
|
||||
case 2:
|
||||
button = Button2 // And the middle button as button 3
|
||||
case 3:
|
||||
button = ButtonNone
|
||||
case 0x40:
|
||||
button = WheelUp
|
||||
case 0x41:
|
||||
button = WheelDown
|
||||
case 0x42:
|
||||
button = WheelLeft
|
||||
case 0x43:
|
||||
button = WheelRight
|
||||
}
|
||||
|
||||
if btn&0x4 != 0 {
|
||||
mod |= ModShift
|
||||
}
|
||||
if btn&0x8 != 0 {
|
||||
mod |= ModAlt
|
||||
}
|
||||
if btn&0x10 != 0 {
|
||||
mod |= ModCtrl
|
||||
}
|
||||
|
||||
ip.post(NewEventMouse(x, y, button, mod))
|
||||
}
|
||||
|
||||
func (ip *inputProcessor) handleWinKey(P []int) {
|
||||
// win32-input-mode
|
||||
// ^[ [ Vk ; Sc ; Uc ; Kd ; Cs ; Rc _
|
||||
// Vk: the value of wVirtualKeyCode - any number. If omitted, defaults to '0'.
|
||||
// Sc: the value of wVirtualScanCode - any number. If omitted, defaults to '0'.
|
||||
// Uc: the decimal value of UnicodeChar - for example, NUL is "0", LF is
|
||||
// "10", the character 'A' is "65". If omitted, defaults to '0'.
|
||||
// Kd: the value of bKeyDown - either a '0' or '1'. If omitted, defaults to '0'.
|
||||
// Cs: the value of dwControlKeyState - any number. If omitted, defaults to '0'.
|
||||
// Rc: the value of wRepeatCount - any number. If omitted, defaults to '1'.
|
||||
//
|
||||
// Note that some 3rd party terminal emulators (not Terminal) suffer from a bug
|
||||
// where other events, such as mouse events, are doubly encoded, using Vk 0
|
||||
// for each character. (So a CSI-M sequence is encoded as a series of CSI-_
|
||||
// sequences.) We consider this a bug in those terminal emulators -- Windows 11
|
||||
// Terminal does not suffer this brain damage. (We've observed this with both Alacritty
|
||||
// and WezTerm.)
|
||||
for len(P) < 6 {
|
||||
P = append(P, 0) // ensure sufficient length
|
||||
}
|
||||
if P[3] == 0 {
|
||||
// key up event ignore ignore
|
||||
return
|
||||
}
|
||||
|
||||
if P[0] == 0 && P[1] == 0 && P[2] > 0 && P[2] < 0x80 { // only ASCII in win32-input-mode
|
||||
if ip.nested == nil {
|
||||
ip.nested = &inputProcessor{
|
||||
evch: ip.evch,
|
||||
rows: ip.rows,
|
||||
cols: ip.cols,
|
||||
}
|
||||
}
|
||||
|
||||
ip.nested.ScanUTF8([]byte{byte(P[2])})
|
||||
return
|
||||
}
|
||||
|
||||
key := KeyRune
|
||||
chr := rune(P[2])
|
||||
mod := ModNone
|
||||
rpt := max(1, P[5])
|
||||
if k1, ok := winKeys[P[0]]; ok {
|
||||
chr = 0
|
||||
key = k1
|
||||
} else if chr == 0 && P[0] >= 0x30 && P[0] <= 0x39 {
|
||||
chr = rune(P[0])
|
||||
} else if chr < ' ' && P[0] >= 0x41 && P[0] <= 0x5a {
|
||||
key = Key(P[0])
|
||||
chr = 0
|
||||
|
||||
} else if chr >= 0xD800 && chr <= 0xDBFF {
|
||||
// high surrogate pair
|
||||
ip.surrogate = chr
|
||||
return
|
||||
} else if chr >= 0xDC00 && chr <= 0xDFFF {
|
||||
// low surrogate pair
|
||||
chr = utf16.DecodeRune(ip.surrogate, chr)
|
||||
} else if P[0] == 0x10 || P[0] == 0x11 || P[0] == 0x12 || P[0] == 0x14 {
|
||||
// lone modifiers
|
||||
ip.surrogate = 0
|
||||
return
|
||||
}
|
||||
|
||||
ip.surrogate = 0
|
||||
|
||||
// Modifiers
|
||||
if P[4]&0x010 != 0 {
|
||||
mod |= ModShift
|
||||
}
|
||||
if P[4]&0x000c != 0 {
|
||||
mod |= ModCtrl
|
||||
}
|
||||
if P[4]&0x0003 != 0 {
|
||||
mod |= ModAlt
|
||||
}
|
||||
if key == KeyRune && chr > ' ' && mod == ModShift {
|
||||
// filter out lone shift for printable chars
|
||||
mod = ModNone
|
||||
}
|
||||
if chr != 0 && mod&(ModCtrl|ModAlt) == ModCtrl|ModAlt {
|
||||
// Filter out ctrl+alt (it means AltGr)
|
||||
mod = ModNone
|
||||
}
|
||||
|
||||
for range rpt {
|
||||
if key != KeyRune || chr != 0 {
|
||||
ip.post(NewEventKey(key, chr, mod))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ip *inputProcessor) handleCsi(mode rune, params []byte, intermediate []byte) {
|
||||
|
||||
// reset state
|
||||
ip.state = inpStateInit
|
||||
|
||||
if len(intermediate) != 0 {
|
||||
// we don't know what to do with these for now
|
||||
return
|
||||
}
|
||||
|
||||
var parts []string
|
||||
var P []int
|
||||
hasLT := false
|
||||
pstr := string(params)
|
||||
// extract numeric parameters
|
||||
if strings.HasPrefix(pstr, "<") {
|
||||
hasLT = true
|
||||
pstr = pstr[1:]
|
||||
}
|
||||
if pstr != "" && pstr[0] >= '0' && pstr[0] <= '9' {
|
||||
parts = strings.Split(pstr, ";")
|
||||
for i := range parts {
|
||||
if parts[i] != "" {
|
||||
if n, e := strconv.ParseInt(parts[i], 10, 32); e == nil {
|
||||
P = append(P, int(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var P0 int
|
||||
if len(P) > 0 {
|
||||
P0 = P[0]
|
||||
}
|
||||
|
||||
if hasLT {
|
||||
switch mode {
|
||||
case 'm', 'M': // mouse event, we only do SGR tracking
|
||||
ip.handleMouse(mode, P)
|
||||
}
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case 'I': // focus in
|
||||
ip.post(NewEventFocus(true))
|
||||
return
|
||||
case 'O': // focus out
|
||||
ip.post(NewEventFocus(false))
|
||||
return
|
||||
case '[':
|
||||
// linux console F-key - CSI-[ modifies next key
|
||||
ip.state = inpStateLFK
|
||||
return
|
||||
case 'u':
|
||||
// CSI-u kitty keyboard protocol
|
||||
if len(P) > 0 && !hasLT {
|
||||
mod := ModNone
|
||||
key := KeyRune
|
||||
chr := rune(0)
|
||||
if k1, ok := csiUKeys[P0]; ok {
|
||||
key = k1.Key
|
||||
chr = k1.Rune
|
||||
} else {
|
||||
chr = rune(P0)
|
||||
}
|
||||
if len(P) > 1 {
|
||||
mod = calcModifier(P[1])
|
||||
}
|
||||
ip.post(NewEventKey(key, chr, mod))
|
||||
}
|
||||
return
|
||||
case '_':
|
||||
if len(intermediate) == 0 && len(P) > 0 {
|
||||
ip.handleWinKey(P)
|
||||
return
|
||||
}
|
||||
case '~':
|
||||
if len(intermediate) == 0 && len(P) >= 2 {
|
||||
mod := calcModifier(P[1])
|
||||
if ks, ok := csiAllKeys[csiParamMode{M: mode, P: P0}]; ok {
|
||||
ip.post(NewEventKey(ks.Key, 0, mod))
|
||||
return
|
||||
}
|
||||
if P0 == 27 && len(P) > 2 && P[2] > 0 && P[2] <= 0xff {
|
||||
if P[2] < ' ' || P[2] == 0x7F {
|
||||
ip.post(NewEventKey(Key(P[2]), 0, mod))
|
||||
} else {
|
||||
ip.post(NewEventKey(KeyRune, rune(P[2]), mod))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ks, ok := csiAllKeys[csiParamMode{M: mode, P: P0}]; ok && !hasLT {
|
||||
if mode == '~' && len(P) > 1 && ks.Mod == ModNone {
|
||||
// apply modifiers if present
|
||||
ks.Mod = calcModifier(P[1])
|
||||
} else if mode == 'P' && os.Getenv("TERM") == "aixterm" {
|
||||
ks.Key = KeyDelete // aixterm hack - conflicts with kitty protocol
|
||||
}
|
||||
ip.post(NewEventKey(ks.Key, 0, ks.Mod))
|
||||
return
|
||||
}
|
||||
|
||||
// this might have been an SS3 style key with modifiers applied
|
||||
if k, ok := ss3Keys[mode]; ok && P0 == 1 && len(P) > 1 {
|
||||
ip.post(NewEventKey(k, 0, calcModifier(P[1])))
|
||||
return
|
||||
}
|
||||
// if we got here we just swallow the unknown sequence
|
||||
}
|
||||
|
||||
func (ip *inputProcessor) ScanUTF8(b []byte) {
|
||||
ip.l.Lock()
|
||||
defer ip.l.Unlock()
|
||||
|
||||
ip.ut8 = append(ip.ut8, b...)
|
||||
for len(ip.ut8) > 0 {
|
||||
// fast path, basic ascii
|
||||
if ip.ut8[0] < 0x7F {
|
||||
ip.buf = append(ip.buf, rune(ip.ut8[0]))
|
||||
ip.ut8 = ip.ut8[1:]
|
||||
} else {
|
||||
r, len := utf8.DecodeRune(ip.ut8)
|
||||
if r == utf8.RuneError {
|
||||
r = rune(ip.ut8[0])
|
||||
len = 1
|
||||
}
|
||||
ip.buf = append(ip.buf, r)
|
||||
ip.ut8 = ip.ut8[len:]
|
||||
}
|
||||
}
|
||||
|
||||
ip.scan()
|
||||
}
|
||||
|
||||
func (ip *inputProcessor) ScanUTF16(u []uint16) {
|
||||
ip.l.Lock()
|
||||
defer ip.l.Unlock()
|
||||
ip.ut16 = append(ip.ut16, u...)
|
||||
for len(ip.ut16) > 0 {
|
||||
if !utf16.IsSurrogate(rune(ip.ut16[0])) {
|
||||
ip.buf = append(ip.buf, rune(ip.ut16[0]))
|
||||
ip.ut16 = ip.ut16[1:]
|
||||
} else if len(ip.ut16) > 1 {
|
||||
ip.buf = append(ip.buf, utf16.DecodeRune(rune(ip.ut16[0]), rune(ip.ut16[1])))
|
||||
ip.ut16 = ip.ut16[2:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
-12
@@ -1,4 +1,4 @@
|
||||
// Copyright 2016 The TCell Authors
|
||||
// Copyright 2025 The TCell Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
@@ -171,6 +171,11 @@ var KeyNames = map[Key]string{
|
||||
KeyF62: "F62",
|
||||
KeyF63: "F63",
|
||||
KeyF64: "F64",
|
||||
KeyMenu: "Menu",
|
||||
KeyCapsLock: "CapsLock",
|
||||
KeyScrollLock: "ScrollLock",
|
||||
KeyNumLock: "NumLock",
|
||||
KeyCtrlSpace: "Ctrl-Space",
|
||||
KeyCtrlA: "Ctrl-A",
|
||||
KeyCtrlB: "Ctrl-B",
|
||||
KeyCtrlC: "Ctrl-C",
|
||||
@@ -178,9 +183,12 @@ var KeyNames = map[Key]string{
|
||||
KeyCtrlE: "Ctrl-E",
|
||||
KeyCtrlF: "Ctrl-F",
|
||||
KeyCtrlG: "Ctrl-G",
|
||||
KeyCtrlH: "Ctrl-H",
|
||||
KeyCtrlI: "Ctrl-I",
|
||||
KeyCtrlJ: "Ctrl-J",
|
||||
KeyCtrlK: "Ctrl-K",
|
||||
KeyCtrlL: "Ctrl-L",
|
||||
KeyCtrlM: "Ctrl-M",
|
||||
KeyCtrlN: "Ctrl-N",
|
||||
KeyCtrlO: "Ctrl-O",
|
||||
KeyCtrlP: "Ctrl-P",
|
||||
@@ -194,11 +202,11 @@ var KeyNames = map[Key]string{
|
||||
KeyCtrlX: "Ctrl-X",
|
||||
KeyCtrlY: "Ctrl-Y",
|
||||
KeyCtrlZ: "Ctrl-Z",
|
||||
KeyCtrlSpace: "Ctrl-Space",
|
||||
KeyCtrlUnderscore: "Ctrl-_",
|
||||
KeyCtrlLeftSq: "Ctrl-[",
|
||||
KeyCtrlRightSq: "Ctrl-]",
|
||||
KeyCtrlBackslash: "Ctrl-\\",
|
||||
KeyCtrlCarat: "Ctrl-^",
|
||||
KeyCtrlUnderscore: "Ctrl-_",
|
||||
}
|
||||
|
||||
// Name returns a printable value or the key stroke. This can be used
|
||||
@@ -218,6 +226,9 @@ func (ev *EventKey) Name() string {
|
||||
if ev.mod&ModCtrl != 0 {
|
||||
m = append(m, "Ctrl")
|
||||
}
|
||||
if ev.mod&ModHyper != 0 {
|
||||
m = append(m, "Hyper")
|
||||
}
|
||||
|
||||
ok := false
|
||||
if s, ok = KeyNames[ev.key]; !ok {
|
||||
@@ -246,15 +257,52 @@ func NewEventKey(k Key, ch rune, mod ModMask) *EventKey {
|
||||
// control characters and the DEL.
|
||||
k = Key(ch)
|
||||
if mod == ModNone && ch < ' ' {
|
||||
switch Key(ch) {
|
||||
switch k {
|
||||
case KeyBackspace, KeyTab, KeyEsc, KeyEnter:
|
||||
// these keys are directly typeable without CTRL
|
||||
default:
|
||||
// most likely entered with a CTRL keypress
|
||||
mod = ModCtrl
|
||||
}
|
||||
ch = ch + '\x60'
|
||||
}
|
||||
}
|
||||
if k == KeyRune && ch >= 'A' && ch <= 'Z' && mod == ModCtrl {
|
||||
// We don't do Ctrl-[ or backslash or those specially.
|
||||
k = KeyCtrlA + Key(ch-'A')
|
||||
}
|
||||
|
||||
// Might be lower case
|
||||
if k == KeyRune && ch >= 'a' && ch <= 'z' && mod == ModCtrl {
|
||||
// We don't do Ctrl-[ or backslash or those specially.
|
||||
k = KeyCtrlA + Key(ch-'a')
|
||||
}
|
||||
|
||||
// Windows reports ModShift for shifted keys. This is inconsistent
|
||||
// with UNIX, lets harmonize this.
|
||||
if k == KeyRune && mod == ModShift && ch != 0 {
|
||||
mod = ModNone
|
||||
}
|
||||
|
||||
if k >= KeyCtrlA && k <= KeyCtrlZ {
|
||||
if mod&ModShift != 0 {
|
||||
ch = rune((k - KeyCtrlA) + 'A')
|
||||
} else {
|
||||
ch = rune((k - KeyCtrlA) + 'a')
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace2 is just another name for backspace.
|
||||
if k == KeyBackspace2 {
|
||||
k = KeyBackspace
|
||||
}
|
||||
|
||||
// Shift-Tab should be Backtab.
|
||||
if k == KeyTab && (mod&ModShift) != 0 {
|
||||
k = KeyBacktab
|
||||
mod &^= ModShift
|
||||
}
|
||||
|
||||
return &EventKey{t: time.Now(), key: k, ch: ch, mod: mod}
|
||||
}
|
||||
|
||||
@@ -272,6 +320,7 @@ const (
|
||||
ModCtrl
|
||||
ModAlt
|
||||
ModMeta
|
||||
ModHyper
|
||||
ModNone ModMask = 0
|
||||
)
|
||||
|
||||
@@ -373,6 +422,10 @@ const (
|
||||
KeyF62
|
||||
KeyF63
|
||||
KeyF64
|
||||
KeyMenu
|
||||
KeyCapsLock
|
||||
KeyScrollLock
|
||||
KeyNumLock
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -381,10 +434,12 @@ const (
|
||||
keyPasteEnd
|
||||
)
|
||||
|
||||
// These are the control keys. Note that they overlap with other keys,
|
||||
// perhaps. For example, KeyCtrlH is the same as KeyBackspace.
|
||||
// These are the control keys, they will also be reported with the
|
||||
// rune (lower case) and control modifier. If the shift key
|
||||
// or other modifiers are present then these will *NOT* be reported,
|
||||
// but reported instead as KeyRune.
|
||||
const (
|
||||
KeyCtrlSpace Key = iota
|
||||
KeyCtrlSpace Key = iota + 64
|
||||
KeyCtrlA
|
||||
KeyCtrlB
|
||||
KeyCtrlC
|
||||
@@ -461,10 +516,12 @@ const (
|
||||
|
||||
// These keys are aliases for other names.
|
||||
const (
|
||||
KeyBackspace = KeyBS
|
||||
KeyTab = KeyTAB
|
||||
KeyEsc = KeyESC
|
||||
KeyEscape = KeyESC
|
||||
KeyEnter = KeyCR
|
||||
KeyBackspace = KeyBS
|
||||
KeyTab = KeyTAB
|
||||
KeyEsc = KeyESC
|
||||
KeyEscape = KeyESC
|
||||
KeyEnter = KeyCR
|
||||
|
||||
// NB: This key will be translated to KeyBackspace
|
||||
KeyBackspace2 = KeyDEL
|
||||
)
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// Copyright 2020 The TCell Authors
|
||||
// Copyright 2025 The TCell Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
|
||||
+70
-20
@@ -1,4 +1,4 @@
|
||||
// Copyright 2024 The TCell Authors
|
||||
// Copyright 2025 The TCell Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
@@ -35,17 +35,37 @@ type Screen interface {
|
||||
// is called (or Sync).
|
||||
Fill(rune, Style)
|
||||
|
||||
// SetCell is an older API, and will be removed. Please use
|
||||
// SetContent instead; SetCell is implemented in terms of SetContent.
|
||||
// Put writes the first graphme of the given string with th
|
||||
// given style at the given coordinates. (Only the first grapheme
|
||||
// occupying either one or two cells is stored.) It returns the
|
||||
// remainder of the string, and the width displayed.
|
||||
Put(x int, y int, str string, style Style) (string, int)
|
||||
|
||||
// PutStr writes a string starting at the given position, using the
|
||||
// default style. The content is clipped to the screen dimensions.
|
||||
PutStr(x int, y int, str string)
|
||||
|
||||
// PutStrStyled writes a string starting at the given position, using
|
||||
// the given style. The cont4ent is clipped to the screen dimensions.
|
||||
PutStrStyled(x int, y int, str string, style Style)
|
||||
|
||||
// SetCell is an older API, and will be removed.
|
||||
//jj
|
||||
// Deprecated: Please use Put instead.
|
||||
SetCell(x int, y int, style Style, ch ...rune)
|
||||
|
||||
// GetContent returns the contents at the given location. If the
|
||||
// Get the contents at the given location. If the
|
||||
// coordinates are out of range, then the values will be 0, nil,
|
||||
// StyleDefault. Note that the contents returned are logical contents
|
||||
// and may not actually be what is displayed, but rather are what will
|
||||
// be displayed if Show() or Sync() is called. The width is the width
|
||||
// in screen cells; most often this will be 1, but some East Asian
|
||||
// characters and emoji require two cells.
|
||||
Get(x, y int) (str string, style Style, width int)
|
||||
|
||||
// GetContent is the old way to get cell contents.
|
||||
//
|
||||
// Deprecated: Use Get() instead.
|
||||
GetContent(x, y int) (primary rune, combining []rune, style Style, width int)
|
||||
|
||||
// SetContent sets the contents of the given cell location. If
|
||||
@@ -221,6 +241,9 @@ type Screen interface {
|
||||
// fallbacks are registered, this will return true. This will
|
||||
// also return true if the terminal can replace the glyph with
|
||||
// one that is visually indistinguishable from the one requested.
|
||||
//
|
||||
// Deprecated: This is not a particularly useful or reliable function,
|
||||
// due to limitations in fonts, etc. It will be removed in the future.
|
||||
CanDisplay(r rune, checkFallbacks bool) bool
|
||||
|
||||
// Resize does nothing, since it's generally not possible to
|
||||
@@ -228,14 +251,13 @@ type Screen interface {
|
||||
// the View interface.
|
||||
Resize(int, int, int, int)
|
||||
|
||||
// HasKey returns true if the keyboard is believed to have the
|
||||
// key. In some cases a keyboard may have keys with this name
|
||||
// but no support for them, while in others a key may be reported
|
||||
// as supported but not actually be usable (such as some emulators
|
||||
// that hijack certain keys). Its best not to depend to strictly
|
||||
// on this function, but it can be used for hinting when building
|
||||
// menus, displayed hot-keys, etc. Note that KeyRune (literal
|
||||
// runes) is always true.
|
||||
// HasKey always returns true.
|
||||
//
|
||||
// Deprecated: This function always returns true. Applications
|
||||
// cannot reliably detect whether a key is supported or not with
|
||||
// modern terminal emulators. (The intended use here was to help
|
||||
// applications determine whether a given key stroke was supported
|
||||
// by the terminal, but it was never reliable.)
|
||||
HasKey(Key) bool
|
||||
|
||||
// Suspend pauses input and output processing. It also restores the
|
||||
@@ -288,10 +310,9 @@ type Screen interface {
|
||||
// NewScreen returns a default Screen suitable for the user's terminal
|
||||
// environment.
|
||||
func NewScreen() (Screen, error) {
|
||||
// Windows is happier if we try for a console screen first.
|
||||
if s, _ := NewConsoleScreen(); s != nil {
|
||||
if s, e := NewTerminfoScreen(); s != nil {
|
||||
return s, nil
|
||||
} else if s, e := NewTerminfoScreen(); s != nil {
|
||||
} else if s, _ := NewConsoleScreen(); s != nil {
|
||||
return s, nil
|
||||
} else {
|
||||
return nil, e
|
||||
@@ -382,11 +403,37 @@ type baseScreen struct {
|
||||
screenImpl
|
||||
}
|
||||
|
||||
func (b *baseScreen) Put(x int, y int, str string, style Style) (remain string, width int) {
|
||||
cells := b.GetCells()
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return cells.Put(x, y, str, style)
|
||||
}
|
||||
|
||||
func (b *baseScreen) PutStrStyled(x int, y int, str string, style Style) {
|
||||
cells := b.GetCells()
|
||||
b.Lock()
|
||||
cols, rows := cells.Size()
|
||||
width := 0
|
||||
for str != "" && x < cols && y < rows {
|
||||
str, width = cells.Put(x, y, str, style)
|
||||
if width == 0 {
|
||||
break
|
||||
}
|
||||
x += width
|
||||
}
|
||||
defer b.Unlock()
|
||||
}
|
||||
|
||||
func (b *baseScreen) PutStr(x, y int, str string) {
|
||||
b.PutStrStyled(x, y, str, StyleDefault)
|
||||
}
|
||||
|
||||
func (b *baseScreen) SetCell(x int, y int, style Style, ch ...rune) {
|
||||
if len(ch) > 0 {
|
||||
b.SetContent(x, y, ch[0], ch[1:], style)
|
||||
b.Put(x, y, string(ch), style)
|
||||
} else {
|
||||
b.SetContent(x, y, ' ', nil, style)
|
||||
b.Put(x, y, " ", style)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,12 +448,15 @@ func (b *baseScreen) Fill(r rune, style Style) {
|
||||
b.Unlock()
|
||||
}
|
||||
|
||||
func (b *baseScreen) SetContent(x, y int, mainc rune, combc []rune, st Style) {
|
||||
func (b *baseScreen) SetContent(x, y int, mainc rune, combc []rune, style Style) {
|
||||
b.Put(x, y, string(append([]rune{mainc}, combc...)), style)
|
||||
}
|
||||
|
||||
func (b *baseScreen) Get(x, y int) (string, Style, int) {
|
||||
cells := b.GetCells()
|
||||
b.Lock()
|
||||
cells.SetContent(x, y, mainc, combc, st)
|
||||
b.Unlock()
|
||||
defer b.Unlock()
|
||||
return cells.Get(x, y)
|
||||
}
|
||||
|
||||
func (b *baseScreen) GetContent(x, y int) (rune, []rune, Style, int) {
|
||||
|
||||
+14
-3
@@ -143,6 +143,10 @@ func (s *simscreen) Init() error {
|
||||
|
||||
func (s *simscreen) Fini() {
|
||||
s.Lock()
|
||||
if s.fini {
|
||||
s.Unlock()
|
||||
return
|
||||
}
|
||||
s.fini = true
|
||||
s.back.Resize(0, 0)
|
||||
s.Unlock()
|
||||
@@ -356,11 +360,18 @@ outer:
|
||||
}
|
||||
|
||||
if b[0] < 0x80 {
|
||||
mod := ModNone
|
||||
// No encodings start with low numbered values
|
||||
if Key(b[0]) >= KeyCtrlA && Key(b[0]) <= KeyCtrlZ {
|
||||
mod = ModCtrl
|
||||
if b[0] > 0 && b[0] < ' ' { // control keys
|
||||
switch Key(b[0]) {
|
||||
case KeyESC, KeyEnter, KeyTAB:
|
||||
s.postEvent(NewEventKey(Key(b[0]), 0, 0))
|
||||
continue;
|
||||
default:
|
||||
s.postEvent(NewEventKey(Key(b[0]), rune(b[0])+'\x60', ModCtrl))
|
||||
continue
|
||||
}
|
||||
}
|
||||
mod := ModNone
|
||||
ev := NewEventKey(Key(b[0]), 0, mod)
|
||||
s.postEvent(ev)
|
||||
b = b[1:]
|
||||
|
||||
+42
-2
@@ -14,6 +14,11 @@
|
||||
|
||||
package tcell
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Style represents a complete text style, including both foreground color,
|
||||
// background color, and additional attributes such as "bold" or "underline".
|
||||
//
|
||||
@@ -164,6 +169,16 @@ func (s Style) Underline(params ...interface{}) Style {
|
||||
return s2
|
||||
}
|
||||
|
||||
// GetUnderlineStyle returns the underline style for the style.
|
||||
func (s Style) GetUnderlineStyle() UnderlineStyle {
|
||||
return s.ulStyle
|
||||
}
|
||||
|
||||
// GetUnderlineColor returns the underline color for the style.
|
||||
func (s Style) GetUnderlineColor() Color {
|
||||
return s.ulColor
|
||||
}
|
||||
|
||||
// Attributes returns a new style based on s, with its attributes set as
|
||||
// specified.
|
||||
func (s Style) Attributes(attrs AttrMask) Style {
|
||||
@@ -177,7 +192,7 @@ func (s Style) Attributes(attrs AttrMask) Style {
|
||||
// link to that Url. If the Url is empty, then this mode is turned off.
|
||||
func (s Style) Url(url string) Style {
|
||||
s2 := s
|
||||
s2.url = url
|
||||
s2.url = stripOSCControls(url)
|
||||
return s2
|
||||
}
|
||||
|
||||
@@ -187,6 +202,31 @@ func (s Style) Url(url string) Style {
|
||||
// were one Url, even if it spans multiple lines.
|
||||
func (s Style) UrlId(id string) Style {
|
||||
s2 := s
|
||||
s2.urlId = "id=" + id
|
||||
s2.urlId = "id=" + stripOSCControls(id)
|
||||
return s2
|
||||
}
|
||||
|
||||
func stripOSCControls(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for i := 0; i < len(s); {
|
||||
r, size := utf8.DecodeRuneInString(s[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
c := s[i]
|
||||
if c <= 0x1f || c == 0x7f || (c >= 0x80 && c <= 0x9f) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
_ = b.WriteByte(c)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if r <= 0x1f || r == 0x7f || (r >= 0x80 && r <= 0x9f) {
|
||||
i += size
|
||||
continue
|
||||
}
|
||||
b.WriteString(s[i : i+size])
|
||||
i += size
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
+19
-71
@@ -8,76 +8,24 @@ func init() {
|
||||
|
||||
// IBM Aixterm Terminal Emulator
|
||||
terminfo.AddTerminfo(&terminfo.Terminfo{
|
||||
Name: "aixterm",
|
||||
Columns: 80,
|
||||
Lines: 25,
|
||||
Colors: 8,
|
||||
Bell: "\a",
|
||||
Clear: "\x1b[H\x1b[J",
|
||||
AttrOff: "\x1b[0;10m\x1b(B",
|
||||
Underline: "\x1b[4m",
|
||||
Bold: "\x1b[1m",
|
||||
Reverse: "\x1b[7m",
|
||||
SetFg: "\x1b[3%p1%dm",
|
||||
SetBg: "\x1b[4%p1%dm",
|
||||
SetFgBg: "\x1b[3%p1%d;4%p2%dm",
|
||||
ResetFgBg: "\x1b[32m\x1b[40m",
|
||||
PadChar: "\x00",
|
||||
AltChars: "jjkkllmmnnqqttuuvvwwxx",
|
||||
EnterAcs: "\x1b(0",
|
||||
ExitAcs: "\x1b(B",
|
||||
SetCursor: "\x1b[%i%p1%d;%p2%dH",
|
||||
CursorBack1: "\b",
|
||||
CursorUp1: "\x1b[A",
|
||||
KeyUp: "\x1b[A",
|
||||
KeyDown: "\x1b[B",
|
||||
KeyRight: "\x1b[C",
|
||||
KeyLeft: "\x1b[D",
|
||||
KeyInsert: "\x1b[139q",
|
||||
KeyDelete: "\x1b[P",
|
||||
KeyBackspace: "\b",
|
||||
KeyHome: "\x1b[H",
|
||||
KeyEnd: "\x1b[146q",
|
||||
KeyPgUp: "\x1b[150q",
|
||||
KeyPgDn: "\x1b[154q",
|
||||
KeyF1: "\x1b[001q",
|
||||
KeyF2: "\x1b[002q",
|
||||
KeyF3: "\x1b[003q",
|
||||
KeyF4: "\x1b[004q",
|
||||
KeyF5: "\x1b[005q",
|
||||
KeyF6: "\x1b[006q",
|
||||
KeyF7: "\x1b[007q",
|
||||
KeyF8: "\x1b[008q",
|
||||
KeyF9: "\x1b[009q",
|
||||
KeyF10: "\x1b[010q",
|
||||
KeyF11: "\x1b[011q",
|
||||
KeyF12: "\x1b[012q",
|
||||
KeyF13: "\x1b[013q",
|
||||
KeyF14: "\x1b[014q",
|
||||
KeyF15: "\x1b[015q",
|
||||
KeyF16: "\x1b[016q",
|
||||
KeyF17: "\x1b[017q",
|
||||
KeyF18: "\x1b[018q",
|
||||
KeyF19: "\x1b[019q",
|
||||
KeyF20: "\x1b[020q",
|
||||
KeyF21: "\x1b[021q",
|
||||
KeyF22: "\x1b[022q",
|
||||
KeyF23: "\x1b[023q",
|
||||
KeyF24: "\x1b[024q",
|
||||
KeyF25: "\x1b[025q",
|
||||
KeyF26: "\x1b[026q",
|
||||
KeyF27: "\x1b[027q",
|
||||
KeyF28: "\x1b[028q",
|
||||
KeyF29: "\x1b[029q",
|
||||
KeyF30: "\x1b[030q",
|
||||
KeyF31: "\x1b[031q",
|
||||
KeyF32: "\x1b[032q",
|
||||
KeyF33: "\x1b[033q",
|
||||
KeyF34: "\x1b[034q",
|
||||
KeyF35: "\x1b[035q",
|
||||
KeyF36: "\x1b[036q",
|
||||
KeyClear: "\x1b[144q",
|
||||
KeyBacktab: "\x1b[Z",
|
||||
AutoMargin: true,
|
||||
Name: "aixterm",
|
||||
Columns: 80,
|
||||
Lines: 25,
|
||||
Colors: 8,
|
||||
Clear: "\x1b[H\x1b[J",
|
||||
AttrOff: "\x1b[0;10m\x1b(B",
|
||||
Underline: "\x1b[4m",
|
||||
Bold: "\x1b[1m",
|
||||
Reverse: "\x1b[7m",
|
||||
SetFg: "\x1b[3%p1%dm",
|
||||
SetBg: "\x1b[4%p1%dm",
|
||||
SetFgBg: "\x1b[3%p1%d;4%p2%dm",
|
||||
ResetFgBg: "\x1b[32m\x1b[40m",
|
||||
PadChar: "\x00",
|
||||
AltChars: "jjkkllmmnnqqttuuvvwwxx",
|
||||
EnterAcs: "\x1b(0",
|
||||
ExitAcs: "\x1b(B",
|
||||
SetCursor: "\x1b[%i%p1%d;%p2%dH",
|
||||
AutoMargin: true,
|
||||
})
|
||||
}
|
||||
|
||||
-28
@@ -12,7 +12,6 @@ func init() {
|
||||
Columns: 80,
|
||||
Lines: 24,
|
||||
Colors: 16777216,
|
||||
Bell: "\a",
|
||||
Clear: "\x1b[H\x1b[2J",
|
||||
EnterCA: "\x1b[?1049h\x1b[22;0;0t",
|
||||
ExitCA: "\x1b[?1049l\x1b[23;0;0t",
|
||||
@@ -36,33 +35,6 @@ func init() {
|
||||
StrikeThrough: "\x1b[9m",
|
||||
Mouse: "\x1b[M",
|
||||
SetCursor: "\x1b[%i%p1%d;%p2%dH",
|
||||
CursorBack1: "\b",
|
||||
CursorUp1: "\x1b[A",
|
||||
KeyUp: "\x1bOA",
|
||||
KeyDown: "\x1bOB",
|
||||
KeyRight: "\x1bOC",
|
||||
KeyLeft: "\x1bOD",
|
||||
KeyInsert: "\x1b[2~",
|
||||
KeyDelete: "\x1b[3~",
|
||||
KeyBackspace: "\x7f",
|
||||
KeyHome: "\x1bOH",
|
||||
KeyEnd: "\x1bOF",
|
||||
KeyPgUp: "\x1b[5~",
|
||||
KeyPgDn: "\x1b[6~",
|
||||
KeyF1: "\x1bOP",
|
||||
KeyF2: "\x1bOQ",
|
||||
KeyF3: "\x1bOR",
|
||||
KeyF4: "\x1bOS",
|
||||
KeyF5: "\x1b[15~",
|
||||
KeyF6: "\x1b[17~",
|
||||
KeyF7: "\x1b[18~",
|
||||
KeyF8: "\x1b[19~",
|
||||
KeyF9: "\x1b[20~",
|
||||
KeyF10: "\x1b[21~",
|
||||
KeyF11: "\x1b[23~",
|
||||
KeyF12: "\x1b[24~",
|
||||
KeyBacktab: "\x1b[Z",
|
||||
Modifiers: 1,
|
||||
TrueColor: true,
|
||||
AutoMargin: true,
|
||||
})
|
||||
|
||||
-32
@@ -12,7 +12,6 @@ func init() {
|
||||
Columns: 80,
|
||||
Lines: 24,
|
||||
Colors: 256,
|
||||
Bell: "\a",
|
||||
Clear: "\x1b[H\x1b[2J",
|
||||
EnterCA: "\x1b[?1049h\x1b[22;0;0t",
|
||||
ExitCA: "\x1b[?1049l\x1b[23;0;0t",
|
||||
@@ -39,38 +38,7 @@ func init() {
|
||||
StrikeThrough: "\x1b[9m",
|
||||
Mouse: "\x1b[<",
|
||||
SetCursor: "\x1b[%i%p1%d;%p2%dH",
|
||||
CursorBack1: "\b",
|
||||
CursorUp1: "\x1b[A",
|
||||
KeyUp: "\x1bOA",
|
||||
KeyDown: "\x1bOB",
|
||||
KeyRight: "\x1bOC",
|
||||
KeyLeft: "\x1bOD",
|
||||
KeyInsert: "\x1b[2~",
|
||||
KeyDelete: "\x1b[3~",
|
||||
KeyBackspace: "\x7f",
|
||||
KeyHome: "\x1bOH",
|
||||
KeyEnd: "\x1bOF",
|
||||
KeyPgUp: "\x1b[5~",
|
||||
KeyPgDn: "\x1b[6~",
|
||||
KeyF1: "\x1bOP",
|
||||
KeyF2: "\x1bOQ",
|
||||
KeyF3: "\x1bOR",
|
||||
KeyF4: "\x1bOS",
|
||||
KeyF5: "\x1b[15~",
|
||||
KeyF6: "\x1b[17~",
|
||||
KeyF7: "\x1b[18~",
|
||||
KeyF8: "\x1b[19~",
|
||||
KeyF9: "\x1b[20~",
|
||||
KeyF10: "\x1b[21~",
|
||||
KeyF11: "\x1b[23~",
|
||||
KeyF12: "\x1b[24~",
|
||||
KeyBacktab: "\x1b[Z",
|
||||
Modifiers: 1,
|
||||
AutoMargin: true,
|
||||
DoubleUnderline: "\x1b[4:2m",
|
||||
CurlyUnderline: "\x1b[4:3m",
|
||||
DottedUnderline: "\x1b[4:4m",
|
||||
DashedUnderline: "\x1b[4:5m",
|
||||
XTermLike: true,
|
||||
})
|
||||
}
|
||||
|
||||
+20
-31
@@ -8,36 +8,25 @@ func init() {
|
||||
|
||||
// ansi/pc-term compatible with color
|
||||
terminfo.AddTerminfo(&terminfo.Terminfo{
|
||||
Name: "ansi",
|
||||
Columns: 80,
|
||||
Lines: 24,
|
||||
Colors: 8,
|
||||
Bell: "\a",
|
||||
Clear: "\x1b[H\x1b[J",
|
||||
AttrOff: "\x1b[0;10m",
|
||||
Underline: "\x1b[4m",
|
||||
Bold: "\x1b[1m",
|
||||
Blink: "\x1b[5m",
|
||||
Reverse: "\x1b[7m",
|
||||
SetFg: "\x1b[3%p1%dm",
|
||||
SetBg: "\x1b[4%p1%dm",
|
||||
SetFgBg: "\x1b[3%p1%d;4%p2%dm",
|
||||
ResetFgBg: "\x1b[39;49m",
|
||||
PadChar: "\x00",
|
||||
AltChars: "+\x10,\x11-\x18.\x190\xdb`\x04a\xb1f\xf8g\xf1h\xb0j\xd9k\xbfl\xdam\xc0n\xc5o~p\xc4q\xc4r\xc4s_t\xc3u\xb4v\xc1w\xc2x\xb3y\xf3z\xf2{\xe3|\xd8}\x9c~\xfe",
|
||||
EnterAcs: "\x1b[11m",
|
||||
ExitAcs: "\x1b[10m",
|
||||
SetCursor: "\x1b[%i%p1%d;%p2%dH",
|
||||
CursorBack1: "\x1b[D",
|
||||
CursorUp1: "\x1b[A",
|
||||
KeyUp: "\x1b[A",
|
||||
KeyDown: "\x1b[B",
|
||||
KeyRight: "\x1b[C",
|
||||
KeyLeft: "\x1b[D",
|
||||
KeyInsert: "\x1b[L",
|
||||
KeyBackspace: "\b",
|
||||
KeyHome: "\x1b[H",
|
||||
KeyBacktab: "\x1b[Z",
|
||||
AutoMargin: true,
|
||||
Name: "ansi",
|
||||
Columns: 80,
|
||||
Lines: 24,
|
||||
Colors: 8,
|
||||
Clear: "\x1b[H\x1b[J",
|
||||
AttrOff: "\x1b[0;10m",
|
||||
Underline: "\x1b[4m",
|
||||
Bold: "\x1b[1m",
|
||||
Blink: "\x1b[5m",
|
||||
Reverse: "\x1b[7m",
|
||||
SetFg: "\x1b[3%p1%dm",
|
||||
SetBg: "\x1b[4%p1%dm",
|
||||
SetFgBg: "\x1b[3%p1%d;4%p2%dm",
|
||||
ResetFgBg: "\x1b[39;49m",
|
||||
PadChar: "\x00",
|
||||
AltChars: "+\x10,\x11-\x18.\x190\xdb`\x04a\xb1f\xf8g\xf1h\xb0j\xd9k\xbfl\xdam\xc0n\xc5o~p\xc4q\xc4r\xc4s_t\xc3u\xb4v\xc1w\xc2x\xb3y\xf3z\xf2{\xe3|\xd8}\x9c~\xfe",
|
||||
EnterAcs: "\x1b[11m",
|
||||
ExitAcs: "\x1b[10m",
|
||||
SetCursor: "\x1b[%i%p1%d;%p2%dH",
|
||||
AutoMargin: true,
|
||||
})
|
||||
}
|
||||
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
// Generated automatically. DO NOT HAND-EDIT.
|
||||
|
||||
package beterm
|
||||
|
||||
import "github.com/gdamore/tcell/v2/terminfo"
|
||||
|
||||
func init() {
|
||||
|
||||
// BeOS Terminal
|
||||
terminfo.AddTerminfo(&terminfo.Terminfo{
|
||||
Name: "beterm",
|
||||
Columns: 80,
|
||||
Lines: 25,
|
||||
Colors: 8,
|
||||
Bell: "\a",
|
||||
Clear: "\x1b[H\x1b[J",
|
||||
AttrOff: "\x1b[0;10m",
|
||||
Underline: "\x1b[4m",
|
||||
Bold: "\x1b[1m",
|
||||
Reverse: "\x1b[7m",
|
||||
EnterKeypad: "\x1b[?4h",
|
||||
ExitKeypad: "\x1b[?4l",
|
||||
SetFg: "\x1b[3%p1%dm",
|
||||
SetBg: "\x1b[4%p1%dm",
|
||||
SetFgBg: "\x1b[3%p1%d;4%p2%dm",
|
||||
ResetFgBg: "\x1b[m",
|
||||
PadChar: "\x00",
|
||||
SetCursor: "\x1b[%i%p1%d;%p2%dH",
|
||||
CursorBack1: "\b",
|
||||
CursorUp1: "\x1b[A",
|
||||
KeyUp: "\x1b[A",
|
||||
KeyDown: "\x1b[B",
|
||||
KeyRight: "\x1b[C",
|
||||
KeyLeft: "\x1b[D",
|
||||
KeyInsert: "\x1b[2~",
|
||||
KeyDelete: "\x1b[3~",
|
||||
KeyBackspace: "\b",
|
||||
KeyHome: "\x1b[1~",
|
||||
KeyEnd: "\x1b[4~",
|
||||
KeyPgUp: "\x1b[5~",
|
||||
KeyPgDn: "\x1b[6~",
|
||||
KeyF1: "\x1b[11~",
|
||||
KeyF2: "\x1b[12~",
|
||||
KeyF3: "\x1b[13~",
|
||||
KeyF4: "\x1b[14~",
|
||||
KeyF5: "\x1b[15~",
|
||||
KeyF6: "\x1b[16~",
|
||||
KeyF7: "\x1b[17~",
|
||||
KeyF8: "\x1b[18~",
|
||||
KeyF9: "\x1b[19~",
|
||||
KeyF10: "\x1b[20~",
|
||||
KeyF11: "\x1b[21~",
|
||||
KeyF12: "\x1b[22~",
|
||||
AutoMargin: true,
|
||||
InsertChar: "\x1b[@",
|
||||
})
|
||||
}
|
||||
+1
@@ -25,6 +25,7 @@ import (
|
||||
// The following imports just register themselves --
|
||||
// these are the terminal types we aggregate in this package.
|
||||
_ "github.com/gdamore/tcell/v2/terminfo/a/ansi"
|
||||
_ "github.com/gdamore/tcell/v2/terminfo/t/tmux"
|
||||
_ "github.com/gdamore/tcell/v2/terminfo/v/vt100"
|
||||
_ "github.com/gdamore/tcell/v2/terminfo/v/vt102"
|
||||
_ "github.com/gdamore/tcell/v2/terminfo/v/vt220"
|
||||
|
||||
+20
-54
@@ -8,59 +8,25 @@ func init() {
|
||||
|
||||
// ANSI emulation for Cygwin
|
||||
terminfo.AddTerminfo(&terminfo.Terminfo{
|
||||
Name: "cygwin",
|
||||
Colors: 8,
|
||||
Bell: "\a",
|
||||
Clear: "\x1b[H\x1b[J",
|
||||
EnterCA: "\x1b7\x1b[?47h",
|
||||
ExitCA: "\x1b[2J\x1b[?47l\x1b8",
|
||||
AttrOff: "\x1b[0;10m",
|
||||
Underline: "\x1b[4m",
|
||||
Bold: "\x1b[1m",
|
||||
Reverse: "\x1b[7m",
|
||||
SetFg: "\x1b[3%p1%dm",
|
||||
SetBg: "\x1b[4%p1%dm",
|
||||
SetFgBg: "\x1b[3%p1%d;4%p2%dm",
|
||||
ResetFgBg: "\x1b[39;49m",
|
||||
PadChar: "\x00",
|
||||
AltChars: "+\x10,\x11-\x18.\x190\xdb`\x04a\xb1f\xf8g\xf1h\xb0j\xd9k\xbfl\xdam\xc0n\xc5o~p\xc4q\xc4r\xc4s_t\xc3u\xb4v\xc1w\xc2x\xb3y\xf3z\xf2{\xe3|\xd8}\x9c~\xfe",
|
||||
EnterAcs: "\x1b[11m",
|
||||
ExitAcs: "\x1b[10m",
|
||||
SetCursor: "\x1b[%i%p1%d;%p2%dH",
|
||||
CursorBack1: "\b",
|
||||
CursorUp1: "\x1b[A",
|
||||
KeyUp: "\x1b[A",
|
||||
KeyDown: "\x1b[B",
|
||||
KeyRight: "\x1b[C",
|
||||
KeyLeft: "\x1b[D",
|
||||
KeyInsert: "\x1b[2~",
|
||||
KeyDelete: "\x1b[3~",
|
||||
KeyBackspace: "\b",
|
||||
KeyHome: "\x1b[1~",
|
||||
KeyEnd: "\x1b[4~",
|
||||
KeyPgUp: "\x1b[5~",
|
||||
KeyPgDn: "\x1b[6~",
|
||||
KeyF1: "\x1b[[A",
|
||||
KeyF2: "\x1b[[B",
|
||||
KeyF3: "\x1b[[C",
|
||||
KeyF4: "\x1b[[D",
|
||||
KeyF5: "\x1b[[E",
|
||||
KeyF6: "\x1b[17~",
|
||||
KeyF7: "\x1b[18~",
|
||||
KeyF8: "\x1b[19~",
|
||||
KeyF9: "\x1b[20~",
|
||||
KeyF10: "\x1b[21~",
|
||||
KeyF11: "\x1b[23~",
|
||||
KeyF12: "\x1b[24~",
|
||||
KeyF13: "\x1b[25~",
|
||||
KeyF14: "\x1b[26~",
|
||||
KeyF15: "\x1b[28~",
|
||||
KeyF16: "\x1b[29~",
|
||||
KeyF17: "\x1b[31~",
|
||||
KeyF18: "\x1b[32~",
|
||||
KeyF19: "\x1b[33~",
|
||||
KeyF20: "\x1b[34~",
|
||||
AutoMargin: true,
|
||||
InsertChar: "\x1b[@",
|
||||
Name: "cygwin",
|
||||
Colors: 8,
|
||||
Clear: "\x1b[H\x1b[J",
|
||||
EnterCA: "\x1b7\x1b[?47h",
|
||||
ExitCA: "\x1b[2J\x1b[?47l\x1b8",
|
||||
AttrOff: "\x1b[0;10m",
|
||||
Underline: "\x1b[4m",
|
||||
Bold: "\x1b[1m",
|
||||
Reverse: "\x1b[7m",
|
||||
SetFg: "\x1b[3%p1%dm",
|
||||
SetBg: "\x1b[4%p1%dm",
|
||||
SetFgBg: "\x1b[3%p1%d;4%p2%dm",
|
||||
ResetFgBg: "\x1b[39;49m",
|
||||
PadChar: "\x00",
|
||||
AltChars: "+\x10,\x11-\x18.\x190\xdb`\x04a\xb1f\xf8g\xf1h\xb0j\xd9k\xbfl\xdam\xc0n\xc5o~p\xc4q\xc4r\xc4s_t\xc3u\xb4v\xc1w\xc2x\xb3y\xf3z\xf2{\xe3|\xd8}\x9c~\xfe",
|
||||
EnterAcs: "\x1b[11m",
|
||||
ExitAcs: "\x1b[10m",
|
||||
SetCursor: "\x1b[%i%p1%d;%p2%dH",
|
||||
AutoMargin: true,
|
||||
InsertChar: "\x1b[@",
|
||||
})
|
||||
}
|
||||
|
||||
-33
@@ -12,7 +12,6 @@ func init() {
|
||||
Columns: 80,
|
||||
Lines: 24,
|
||||
Colors: 8,
|
||||
Bell: "\a",
|
||||
Clear: "\x1b[H\x1b[J",
|
||||
ShowCursor: "\x1b[?25h",
|
||||
HideCursor: "\x1b[?25l",
|
||||
@@ -34,38 +33,6 @@ func init() {
|
||||
EnableAutoMargin: "\x1b[?7h",
|
||||
DisableAutoMargin: "\x1b[?7l",
|
||||
SetCursor: "\x1b[%i%p1%d;%p2%dH",
|
||||
CursorBack1: "\b",
|
||||
CursorUp1: "\x1b[A",
|
||||
KeyUp: "\x1b[A",
|
||||
KeyDown: "\x1b[B",
|
||||
KeyRight: "\x1b[C",
|
||||
KeyLeft: "\x1b[D",
|
||||
KeyInsert: "\x1b[2~",
|
||||
KeyDelete: "\x1b[3~",
|
||||
KeyBackspace: "\b",
|
||||
KeyPgUp: "\x1b[5~",
|
||||
KeyPgDn: "\x1b[6~",
|
||||
KeyF1: "\x1b[11~",
|
||||
KeyF2: "\x1b[12~",
|
||||
KeyF3: "\x1b[13~",
|
||||
KeyF4: "\x1b[14~",
|
||||
KeyF5: "\x1b[15~",
|
||||
KeyF6: "\x1b[17~",
|
||||
KeyF7: "\x1b[18~",
|
||||
KeyF8: "\x1b[19~",
|
||||
KeyF9: "\x1b[20~",
|
||||
KeyF10: "\x1b[21~",
|
||||
KeyF11: "\x1b[23~",
|
||||
KeyF12: "\x1b[24~",
|
||||
KeyF13: "\x1b[25~",
|
||||
KeyF14: "\x1b[26~",
|
||||
KeyF15: "\x1b[28~",
|
||||
KeyF16: "\x1b[29~",
|
||||
KeyF17: "\x1b[31~",
|
||||
KeyF18: "\x1b[32~",
|
||||
KeyF19: "\x1b[33~",
|
||||
KeyF20: "\x1b[34~",
|
||||
KeyHelp: "\x1b[28~",
|
||||
AutoMargin: true,
|
||||
})
|
||||
}
|
||||
|
||||
+3
-160
@@ -24,6 +24,7 @@ package dynamic
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -126,7 +127,7 @@ func (tc *termcap) setupterm(name string) error {
|
||||
tc.nums = make(map[string]int)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("couldn't open terminfo ($TERM) file for %s: %w", name, err)
|
||||
}
|
||||
|
||||
// Now parse the output.
|
||||
@@ -144,9 +145,7 @@ func (tc *termcap) setupterm(name string) error {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
header := lines[0]
|
||||
if strings.HasSuffix(header, ",") {
|
||||
header = header[:len(header)-1]
|
||||
}
|
||||
header = strings.TrimSuffix(header, ",")
|
||||
names := strings.Split(header, "|")
|
||||
tc.name = names[0]
|
||||
names = names[1:]
|
||||
@@ -193,7 +192,6 @@ func LoadTerminfo(name string) (*terminfo.Terminfo, string, error) {
|
||||
t.Colors = tc.getnum("colors")
|
||||
t.Columns = tc.getnum("cols")
|
||||
t.Lines = tc.getnum("lines")
|
||||
t.Bell = tc.getstr("bel")
|
||||
t.Clear = tc.getstr("clear")
|
||||
t.EnterCA = tc.getstr("smcup")
|
||||
t.ExitCA = tc.getstr("rmcup")
|
||||
@@ -211,166 +209,11 @@ func LoadTerminfo(name string) (*terminfo.Terminfo, string, error) {
|
||||
t.SetFg = tc.getstr("setaf")
|
||||
t.SetBg = tc.getstr("setab")
|
||||
t.SetCursor = tc.getstr("cup")
|
||||
t.CursorBack1 = tc.getstr("cub1")
|
||||
t.CursorUp1 = tc.getstr("cuu1")
|
||||
t.KeyF1 = tc.getstr("kf1")
|
||||
t.KeyF2 = tc.getstr("kf2")
|
||||
t.KeyF3 = tc.getstr("kf3")
|
||||
t.KeyF4 = tc.getstr("kf4")
|
||||
t.KeyF5 = tc.getstr("kf5")
|
||||
t.KeyF6 = tc.getstr("kf6")
|
||||
t.KeyF7 = tc.getstr("kf7")
|
||||
t.KeyF8 = tc.getstr("kf8")
|
||||
t.KeyF9 = tc.getstr("kf9")
|
||||
t.KeyF10 = tc.getstr("kf10")
|
||||
t.KeyF11 = tc.getstr("kf11")
|
||||
t.KeyF12 = tc.getstr("kf12")
|
||||
t.KeyF13 = tc.getstr("kf13")
|
||||
t.KeyF14 = tc.getstr("kf14")
|
||||
t.KeyF15 = tc.getstr("kf15")
|
||||
t.KeyF16 = tc.getstr("kf16")
|
||||
t.KeyF17 = tc.getstr("kf17")
|
||||
t.KeyF18 = tc.getstr("kf18")
|
||||
t.KeyF19 = tc.getstr("kf19")
|
||||
t.KeyF20 = tc.getstr("kf20")
|
||||
t.KeyF21 = tc.getstr("kf21")
|
||||
t.KeyF22 = tc.getstr("kf22")
|
||||
t.KeyF23 = tc.getstr("kf23")
|
||||
t.KeyF24 = tc.getstr("kf24")
|
||||
t.KeyF25 = tc.getstr("kf25")
|
||||
t.KeyF26 = tc.getstr("kf26")
|
||||
t.KeyF27 = tc.getstr("kf27")
|
||||
t.KeyF28 = tc.getstr("kf28")
|
||||
t.KeyF29 = tc.getstr("kf29")
|
||||
t.KeyF30 = tc.getstr("kf30")
|
||||
t.KeyF31 = tc.getstr("kf31")
|
||||
t.KeyF32 = tc.getstr("kf32")
|
||||
t.KeyF33 = tc.getstr("kf33")
|
||||
t.KeyF34 = tc.getstr("kf34")
|
||||
t.KeyF35 = tc.getstr("kf35")
|
||||
t.KeyF36 = tc.getstr("kf36")
|
||||
t.KeyF37 = tc.getstr("kf37")
|
||||
t.KeyF38 = tc.getstr("kf38")
|
||||
t.KeyF39 = tc.getstr("kf39")
|
||||
t.KeyF40 = tc.getstr("kf40")
|
||||
t.KeyF41 = tc.getstr("kf41")
|
||||
t.KeyF42 = tc.getstr("kf42")
|
||||
t.KeyF43 = tc.getstr("kf43")
|
||||
t.KeyF44 = tc.getstr("kf44")
|
||||
t.KeyF45 = tc.getstr("kf45")
|
||||
t.KeyF46 = tc.getstr("kf46")
|
||||
t.KeyF47 = tc.getstr("kf47")
|
||||
t.KeyF48 = tc.getstr("kf48")
|
||||
t.KeyF49 = tc.getstr("kf49")
|
||||
t.KeyF50 = tc.getstr("kf50")
|
||||
t.KeyF51 = tc.getstr("kf51")
|
||||
t.KeyF52 = tc.getstr("kf52")
|
||||
t.KeyF53 = tc.getstr("kf53")
|
||||
t.KeyF54 = tc.getstr("kf54")
|
||||
t.KeyF55 = tc.getstr("kf55")
|
||||
t.KeyF56 = tc.getstr("kf56")
|
||||
t.KeyF57 = tc.getstr("kf57")
|
||||
t.KeyF58 = tc.getstr("kf58")
|
||||
t.KeyF59 = tc.getstr("kf59")
|
||||
t.KeyF60 = tc.getstr("kf60")
|
||||
t.KeyF61 = tc.getstr("kf61")
|
||||
t.KeyF62 = tc.getstr("kf62")
|
||||
t.KeyF63 = tc.getstr("kf63")
|
||||
t.KeyF64 = tc.getstr("kf64")
|
||||
t.KeyInsert = tc.getstr("kich1")
|
||||
t.KeyDelete = tc.getstr("kdch1")
|
||||
t.KeyBackspace = tc.getstr("kbs")
|
||||
t.KeyHome = tc.getstr("khome")
|
||||
t.KeyEnd = tc.getstr("kend")
|
||||
t.KeyUp = tc.getstr("kcuu1")
|
||||
t.KeyDown = tc.getstr("kcud1")
|
||||
t.KeyRight = tc.getstr("kcuf1")
|
||||
t.KeyLeft = tc.getstr("kcub1")
|
||||
t.KeyPgDn = tc.getstr("knp")
|
||||
t.KeyPgUp = tc.getstr("kpp")
|
||||
t.KeyBacktab = tc.getstr("kcbt")
|
||||
t.KeyExit = tc.getstr("kext")
|
||||
t.KeyCancel = tc.getstr("kcan")
|
||||
t.KeyPrint = tc.getstr("kprt")
|
||||
t.KeyHelp = tc.getstr("khlp")
|
||||
t.KeyClear = tc.getstr("kclr")
|
||||
t.AltChars = tc.getstr("acsc")
|
||||
t.EnterAcs = tc.getstr("smacs")
|
||||
t.ExitAcs = tc.getstr("rmacs")
|
||||
t.EnableAcs = tc.getstr("enacs")
|
||||
t.Mouse = tc.getstr("kmous")
|
||||
t.KeyShfRight = tc.getstr("kRIT")
|
||||
t.KeyShfLeft = tc.getstr("kLFT")
|
||||
t.KeyShfHome = tc.getstr("kHOM")
|
||||
t.KeyShfEnd = tc.getstr("kEND")
|
||||
|
||||
// Terminfo lacks descriptions for a bunch of modified keys,
|
||||
// but modern XTerm and emulators often have them. Let's add them,
|
||||
// if the shifted right and left arrows are defined.
|
||||
if t.KeyShfRight == "\x1b[1;2C" && t.KeyShfLeft == "\x1b[1;2D" {
|
||||
t.Modifiers = terminfo.ModifiersXTerm
|
||||
|
||||
t.KeyShfUp = "\x1b[1;2A"
|
||||
t.KeyShfDown = "\x1b[1;2B"
|
||||
t.KeyMetaUp = "\x1b[1;9A"
|
||||
t.KeyMetaDown = "\x1b[1;9B"
|
||||
t.KeyMetaRight = "\x1b[1;9C"
|
||||
t.KeyMetaLeft = "\x1b[1;9D"
|
||||
t.KeyAltUp = "\x1b[1;3A"
|
||||
t.KeyAltDown = "\x1b[1;3B"
|
||||
t.KeyAltRight = "\x1b[1;3C"
|
||||
t.KeyAltLeft = "\x1b[1;3D"
|
||||
t.KeyCtrlUp = "\x1b[1;5A"
|
||||
t.KeyCtrlDown = "\x1b[1;5B"
|
||||
t.KeyCtrlRight = "\x1b[1;5C"
|
||||
t.KeyCtrlLeft = "\x1b[1;5D"
|
||||
t.KeyAltShfUp = "\x1b[1;4A"
|
||||
t.KeyAltShfDown = "\x1b[1;4B"
|
||||
t.KeyAltShfRight = "\x1b[1;4C"
|
||||
t.KeyAltShfLeft = "\x1b[1;4D"
|
||||
|
||||
t.KeyMetaShfUp = "\x1b[1;10A"
|
||||
t.KeyMetaShfDown = "\x1b[1;10B"
|
||||
t.KeyMetaShfRight = "\x1b[1;10C"
|
||||
t.KeyMetaShfLeft = "\x1b[1;10D"
|
||||
|
||||
t.KeyCtrlShfUp = "\x1b[1;6A"
|
||||
t.KeyCtrlShfDown = "\x1b[1;6B"
|
||||
t.KeyCtrlShfRight = "\x1b[1;6C"
|
||||
t.KeyCtrlShfLeft = "\x1b[1;6D"
|
||||
|
||||
t.KeyShfPgUp = "\x1b[5;2~"
|
||||
t.KeyShfPgDn = "\x1b[6;2~"
|
||||
}
|
||||
// And also for Home and End
|
||||
if t.KeyShfHome == "\x1b[1;2H" && t.KeyShfEnd == "\x1b[1;2F" {
|
||||
t.KeyCtrlHome = "\x1b[1;5H"
|
||||
t.KeyCtrlEnd = "\x1b[1;5F"
|
||||
t.KeyAltHome = "\x1b[1;9H"
|
||||
t.KeyAltEnd = "\x1b[1;9F"
|
||||
t.KeyCtrlShfHome = "\x1b[1;6H"
|
||||
t.KeyCtrlShfEnd = "\x1b[1;6F"
|
||||
t.KeyAltShfHome = "\x1b[1;4H"
|
||||
t.KeyAltShfEnd = "\x1b[1;4F"
|
||||
t.KeyMetaShfHome = "\x1b[1;10H"
|
||||
t.KeyMetaShfEnd = "\x1b[1;10F"
|
||||
}
|
||||
|
||||
// And the same thing for rxvt and workalikes (Eterm, aterm, etc.)
|
||||
// It seems that urxvt at least send escaped as ALT prefix for these,
|
||||
// although some places seem to indicate a separate ALT key sesquence.
|
||||
if t.KeyShfRight == "\x1b[c" && t.KeyShfLeft == "\x1b[d" {
|
||||
t.KeyShfUp = "\x1b[a"
|
||||
t.KeyShfDown = "\x1b[b"
|
||||
t.KeyCtrlUp = "\x1b[Oa"
|
||||
t.KeyCtrlDown = "\x1b[Ob"
|
||||
t.KeyCtrlRight = "\x1b[Oc"
|
||||
t.KeyCtrlLeft = "\x1b[Od"
|
||||
}
|
||||
if t.KeyShfHome == "\x1b[7$" && t.KeyShfEnd == "\x1b[8$" {
|
||||
t.KeyCtrlHome = "\x1b[7^"
|
||||
t.KeyCtrlEnd = "\x1b[8^"
|
||||
}
|
||||
|
||||
// Technically the RGB flag that is provided for xterm-direct is not
|
||||
// quite right. The problem is that the -direct flag that was introduced
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user