5 Commits

Author SHA1 Message Date
77436757c8 fix(type_mapper): update timestamp type mapping to use SqlTimeStamp
All checks were successful
CI / Test (1.24) (push) Successful in -25m13s
CI / Test (1.25) (push) Successful in -25m10s
CI / Build (push) Successful in -26m2s
CI / Lint (push) Successful in -25m39s
Release / Build and Release (push) Successful in -25m49s
Integration Tests / Integration Tests (push) Successful in -25m26s
2026-02-08 21:35:27 +02:00
5e6f03e412 feat(type_mapper): add support for serial types and auto-increment tags
All checks were successful
CI / Build (push) Successful in -25m39s
Integration Tests / Integration Tests (push) Successful in -25m15s
CI / Test (1.24) (push) Successful in -24m39s
CI / Test (1.25) (push) Successful in -24m24s
CI / Lint (push) Successful in -25m9s
Release / Build and Release (push) Successful in -25m21s
2026-02-08 17:48:58 +02:00
1dcbc79387 feat(pgsql): enhance data type mapping to support serial types
All checks were successful
CI / Test (1.25) (push) Successful in -24m18s
CI / Test (1.24) (push) Successful in -24m6s
CI / Build (push) Successful in -25m14s
CI / Lint (push) Successful in -24m47s
Release / Build and Release (push) Successful in -25m37s
Integration Tests / Integration Tests (push) Successful in -25m9s
2026-02-08 17:31:28 +02:00
59c4a5ebf8 test(writer): enhance has-many relationship tests with join tag verification
All checks were successful
CI / Test (1.24) (push) Successful in -25m9s
CI / Test (1.25) (push) Successful in -25m0s
CI / Build (push) Successful in -25m57s
CI / Lint (push) Successful in -25m29s
Release / Build and Release (push) Successful in -25m38s
Integration Tests / Integration Tests (push) Successful in -25m19s
2026-02-08 15:20:20 +02:00
091e1913ee feat(version): retrieve version and build date from VCS if unset
All checks were successful
CI / Test (1.24) (push) Successful in -25m19s
CI / Test (1.25) (push) Successful in -25m1s
CI / Build (push) Successful in -25m56s
CI / Lint (push) Successful in -25m33s
Integration Tests / Integration Tests (push) Successful in -25m32s
2026-02-08 15:04:03 +02:00
8 changed files with 187 additions and 57 deletions

View File

@@ -2,6 +2,8 @@ package main
import ( import (
"fmt" "fmt"
"runtime/debug"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -12,6 +14,36 @@ var (
buildDate = "unknown" buildDate = "unknown"
) )
func init() {
// If version wasn't set via ldflags, try to get it from build info
if version == "dev" {
if info, ok := debug.ReadBuildInfo(); ok {
// Try to get version from VCS
var vcsRevision, vcsTime string
for _, setting := range info.Settings {
switch setting.Key {
case "vcs.revision":
if len(setting.Value) >= 7 {
vcsRevision = setting.Value[:7]
}
case "vcs.time":
vcsTime = setting.Value
}
}
if vcsRevision != "" {
version = vcsRevision
}
if vcsTime != "" {
if t, err := time.Parse(time.RFC3339, vcsTime); err == nil {
buildDate = t.UTC().Format("2006-01-02 15:04:05 UTC")
}
}
}
}
}
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "relspec", Use: "relspec",
Short: "RelSpec - Database schema conversion and analysis tool", Short: "RelSpec - Database schema conversion and analysis tool",

View File

@@ -231,14 +231,13 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
} }
column := models.InitColumn(columnName, tableName, schema) column := models.InitColumn(columnName, tableName, schema)
column.Type = r.mapDataType(dataType, udtName)
column.NotNull = (isNullable == "NO")
column.Sequence = uint(ordinalPosition)
// Check if this is a serial type (has nextval default)
hasNextval := false
if columnDefault != nil { if columnDefault != nil {
// Parse default value - remove nextval for sequences
defaultVal := *columnDefault defaultVal := *columnDefault
if strings.HasPrefix(defaultVal, "nextval") { if strings.HasPrefix(defaultVal, "nextval") {
hasNextval = true
column.AutoIncrement = true column.AutoIncrement = true
column.Default = defaultVal column.Default = defaultVal
} else { } else {
@@ -246,6 +245,11 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
} }
} }
// Map data type, preserving serial types when detected
column.Type = r.mapDataType(dataType, udtName, hasNextval)
column.NotNull = (isNullable == "NO")
column.Sequence = uint(ordinalPosition)
if description != nil { if description != nil {
column.Description = *description column.Description = *description
} }

View File

@@ -3,6 +3,7 @@ package pgsql
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -259,33 +260,46 @@ func (r *Reader) close() {
} }
// mapDataType maps PostgreSQL data types to canonical types // mapDataType maps PostgreSQL data types to canonical types
func (r *Reader) mapDataType(pgType, udtName string) string { func (r *Reader) mapDataType(pgType, udtName string, hasNextval bool) string {
// 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
if hasNextval {
switch strings.ToLower(pgType) {
case "integer", "int", "int4":
return "serial"
case "bigint", "int8":
return "bigserial"
case "smallint", "int2":
return "smallserial"
}
}
// Map common PostgreSQL types // Map common PostgreSQL types
typeMap := map[string]string{ typeMap := map[string]string{
"integer": "int", "integer": "integer",
"bigint": "int64", "bigint": "bigint",
"smallint": "int16", "smallint": "smallint",
"int": "int", "int": "integer",
"int2": "int16", "int2": "smallint",
"int4": "int", "int4": "integer",
"int8": "int64", "int8": "bigint",
"serial": "int", "serial": "serial",
"bigserial": "int64", "bigserial": "bigserial",
"smallserial": "int16", "smallserial": "smallserial",
"numeric": "decimal", "numeric": "numeric",
"decimal": "decimal", "decimal": "decimal",
"real": "float32", "real": "real",
"double precision": "float64", "double precision": "double precision",
"float4": "float32", "float4": "real",
"float8": "float64", "float8": "double precision",
"money": "decimal", "money": "money",
"character varying": "string", "character varying": "varchar",
"varchar": "string", "varchar": "varchar",
"character": "string", "character": "char",
"char": "string", "char": "char",
"text": "string", "text": "text",
"boolean": "bool", "boolean": "boolean",
"bool": "bool", "bool": "boolean",
"date": "date", "date": "date",
"time": "time", "time": "time",
"time without time zone": "time", "time without time zone": "time",

View File

@@ -177,20 +177,20 @@ func TestMapDataType(t *testing.T) {
udtName string udtName string
expected string expected string
}{ }{
{"integer", "int4", "int"}, {"integer", "int4", "integer"},
{"bigint", "int8", "int64"}, {"bigint", "int8", "bigint"},
{"smallint", "int2", "int16"}, {"smallint", "int2", "smallint"},
{"character varying", "varchar", "string"}, {"character varying", "varchar", "varchar"},
{"text", "text", "string"}, {"text", "text", "text"},
{"boolean", "bool", "bool"}, {"boolean", "bool", "boolean"},
{"timestamp without time zone", "timestamp", "timestamp"}, {"timestamp without time zone", "timestamp", "timestamp"},
{"timestamp with time zone", "timestamptz", "timestamptz"}, {"timestamp with time zone", "timestamptz", "timestamptz"},
{"json", "json", "json"}, {"json", "json", "json"},
{"jsonb", "jsonb", "jsonb"}, {"jsonb", "jsonb", "jsonb"},
{"uuid", "uuid", "uuid"}, {"uuid", "uuid", "uuid"},
{"numeric", "numeric", "decimal"}, {"numeric", "numeric", "numeric"},
{"real", "float4", "float32"}, {"real", "float4", "real"},
{"double precision", "float8", "float64"}, {"double precision", "float8", "double precision"},
{"date", "date", "date"}, {"date", "date", "date"},
{"time without time zone", "time", "time"}, {"time without time zone", "time", "time"},
{"bytea", "bytea", "bytea"}, {"bytea", "bytea", "bytea"},
@@ -199,12 +199,31 @@ func TestMapDataType(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.pgType, func(t *testing.T) { t.Run(tt.pgType, func(t *testing.T) {
result := reader.mapDataType(tt.pgType, tt.udtName) result := reader.mapDataType(tt.pgType, tt.udtName, false)
if result != tt.expected { if result != tt.expected {
t.Errorf("mapDataType(%s, %s) = %s, expected %s", tt.pgType, tt.udtName, result, tt.expected) t.Errorf("mapDataType(%s, %s) = %s, expected %s", tt.pgType, tt.udtName, result, tt.expected)
} }
}) })
} }
// Test serial type detection with hasNextval=true
serialTests := []struct {
pgType string
expected string
}{
{"integer", "serial"},
{"bigint", "bigserial"},
{"smallint", "smallserial"},
}
for _, tt := range serialTests {
t.Run(tt.pgType+"_with_nextval", func(t *testing.T) {
result := reader.mapDataType(tt.pgType, "", true)
if result != tt.expected {
t.Errorf("mapDataType(%s, '', true) = %s, expected %s", tt.pgType, result, tt.expected)
}
})
}
} }
func TestParseIndexDefinition(t *testing.T) { func TestParseIndexDefinition(t *testing.T) {

View File

@@ -62,6 +62,17 @@ func (tm *TypeMapper) isSimpleType(sqlType string) bool {
return simpleTypes[sqlType] return simpleTypes[sqlType]
} }
// isSerialType checks if a SQL type is a serial type (auto-incrementing)
func (tm *TypeMapper) isSerialType(sqlType string) bool {
baseType := tm.extractBaseType(sqlType)
serialTypes := map[string]bool{
"serial": true,
"bigserial": true,
"smallserial": true,
}
return serialTypes[baseType]
}
// baseGoType returns the base Go type for a SQL type (not null, simple types only) // baseGoType returns the base Go type for a SQL type (not null, simple types only)
func (tm *TypeMapper) baseGoType(sqlType string) string { func (tm *TypeMapper) baseGoType(sqlType string) string {
typeMap := map[string]string{ typeMap := map[string]string{
@@ -122,10 +133,10 @@ func (tm *TypeMapper) bunGoType(sqlType string) string {
"decimal": tm.sqlTypesAlias + ".SqlFloat64", "decimal": tm.sqlTypesAlias + ".SqlFloat64",
// Date/Time types // Date/Time types
"timestamp": tm.sqlTypesAlias + ".SqlTime", "timestamp": tm.sqlTypesAlias + ".SqlTimeStamp",
"timestamp without time zone": tm.sqlTypesAlias + ".SqlTime", "timestamp without time zone": tm.sqlTypesAlias + ".SqlTimeStamp",
"timestamp with time zone": tm.sqlTypesAlias + ".SqlTime", "timestamp with time zone": tm.sqlTypesAlias + ".SqlTimeStamp",
"timestamptz": tm.sqlTypesAlias + ".SqlTime", "timestamptz": tm.sqlTypesAlias + ".SqlTimeStamp",
"date": tm.sqlTypesAlias + ".SqlDate", "date": tm.sqlTypesAlias + ".SqlDate",
"time": tm.sqlTypesAlias + ".SqlTime", "time": tm.sqlTypesAlias + ".SqlTime",
"time without time zone": tm.sqlTypesAlias + ".SqlTime", "time without time zone": tm.sqlTypesAlias + ".SqlTime",
@@ -190,6 +201,11 @@ func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) st
parts = append(parts, "pk") parts = append(parts, "pk")
} }
// Auto increment (for serial types or explicit auto_increment)
if column.AutoIncrement || tm.isSerialType(column.Type) {
parts = append(parts, "autoincrement")
}
// Default value // Default value
if column.Default != nil { if column.Default != nil {
// Sanitize default value to remove backticks // Sanitize default value to remove backticks
@@ -251,7 +267,15 @@ func (tm *TypeMapper) BuildRelationshipTag(constraint *models.Constraint, relTyp
if len(constraint.Columns) > 0 && len(constraint.ReferencedColumns) > 0 { if len(constraint.Columns) > 0 && len(constraint.ReferencedColumns) > 0 {
localCol := constraint.Columns[0] localCol := constraint.Columns[0]
foreignCol := constraint.ReferencedColumns[0] foreignCol := constraint.ReferencedColumns[0]
parts = append(parts, fmt.Sprintf("join:%s=%s", localCol, foreignCol))
// For has-many relationships, swap the columns
// has-one: join:fk_in_this_table=pk_in_other_table
// has-many: join:pk_in_this_table=fk_in_other_table
if relType == "has-many" {
parts = append(parts, fmt.Sprintf("join:%s=%s", foreignCol, localCol))
} else {
parts = append(parts, fmt.Sprintf("join:%s=%s", localCol, foreignCol))
}
} }
return strings.Join(parts, ",") return strings.Join(parts, ",")

View File

@@ -90,8 +90,8 @@ func TestWriter_WriteTable(t *testing.T) {
} }
// Verify Bun-specific elements // Verify Bun-specific elements
if !strings.Contains(generated, "bun:\"id,type:bigint,pk,") { if !strings.Contains(generated, "bun:\"id,type:bigint,pk,autoincrement,") {
t.Errorf("Missing Bun-style primary key tag") t.Errorf("Missing Bun-style primary key tag with autoincrement")
} }
} }
@@ -308,14 +308,20 @@ func TestWriter_MultipleReferencesToSameTable(t *testing.T) {
filepointerStr := string(filepointerContent) filepointerStr := string(filepointerContent)
// Should have two different has-many relationships with unique names // Should have two different has-many relationships with unique names
hasManyExpectations := []string{ hasManyExpectations := []struct {
"RelRIDFilepointerRequestOrgAPIEvents", // Has many via rid_filepointer_request fieldName string
"RelRIDFilepointerResponseOrgAPIEvents", // Has many via rid_filepointer_response tag string
}{
{"RelRIDFilepointerRequestOrgAPIEvents", "join:id_filepointer=rid_filepointer_request"}, // Has many via rid_filepointer_request
{"RelRIDFilepointerResponseOrgAPIEvents", "join:id_filepointer=rid_filepointer_response"}, // Has many via rid_filepointer_response
} }
for _, exp := range hasManyExpectations { for _, exp := range hasManyExpectations {
if !strings.Contains(filepointerStr, exp) { if !strings.Contains(filepointerStr, exp.fieldName) {
t.Errorf("Missing has-many relationship field: %s\nGenerated:\n%s", exp, filepointerStr) t.Errorf("Missing has-many relationship field: %s\nGenerated:\n%s", exp.fieldName, filepointerStr)
}
if !strings.Contains(filepointerStr, exp.tag) {
t.Errorf("Missing has-many relationship join tag: %s\nGenerated:\n%s", exp.tag, filepointerStr)
} }
} }
} }
@@ -561,8 +567,8 @@ func TestTypeMapper_SQLTypeToGoType_Bun(t *testing.T) {
{"bigint", false, "resolvespec_common.SqlInt64"}, {"bigint", false, "resolvespec_common.SqlInt64"},
{"varchar", true, "resolvespec_common.SqlString"}, // Bun uses sql types even for NOT NULL strings {"varchar", true, "resolvespec_common.SqlString"}, // Bun uses sql types even for NOT NULL strings
{"varchar", false, "resolvespec_common.SqlString"}, {"varchar", false, "resolvespec_common.SqlString"},
{"timestamp", true, "resolvespec_common.SqlTime"}, {"timestamp", true, "resolvespec_common.SqlTimeStamp"},
{"timestamp", false, "resolvespec_common.SqlTime"}, {"timestamp", false, "resolvespec_common.SqlTimeStamp"},
{"date", false, "resolvespec_common.SqlDate"}, {"date", false, "resolvespec_common.SqlDate"},
{"boolean", true, "bool"}, {"boolean", true, "bool"},
{"boolean", false, "resolvespec_common.SqlBool"}, {"boolean", false, "resolvespec_common.SqlBool"},
@@ -618,6 +624,37 @@ func TestTypeMapper_BuildBunTag(t *testing.T) {
}, },
want: []string{"status,", "type:text,", "default:active,"}, want: []string{"status,", "type:text,", "default:active,"},
}, },
{
name: "auto increment with AutoIncrement flag",
column: &models.Column{
Name: "id",
Type: "bigint",
NotNull: true,
IsPrimaryKey: true,
AutoIncrement: true,
},
want: []string{"id,", "type:bigint,", "pk,", "autoincrement,"},
},
{
name: "serial type (auto-increment)",
column: &models.Column{
Name: "id",
Type: "serial",
NotNull: true,
IsPrimaryKey: true,
},
want: []string{"id,", "type:serial,", "pk,", "autoincrement,"},
},
{
name: "bigserial type (auto-increment)",
column: &models.Column{
Name: "id",
Type: "bigserial",
NotNull: true,
IsPrimaryKey: true,
},
want: []string{"id,", "type:bigserial,", "pk,", "autoincrement,"},
},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@@ -158,10 +158,10 @@ func (tm *TypeMapper) nullableGoType(sqlType string) string {
"decimal": tm.sqlTypesAlias + ".SqlFloat64", "decimal": tm.sqlTypesAlias + ".SqlFloat64",
// Date/Time types // Date/Time types
"timestamp": tm.sqlTypesAlias + ".SqlTime", "timestamp": tm.sqlTypesAlias + ".SqlTimeStamp",
"timestamp without time zone": tm.sqlTypesAlias + ".SqlTime", "timestamp without time zone": tm.sqlTypesAlias + ".SqlTimeStamp",
"timestamp with time zone": tm.sqlTypesAlias + ".SqlTime", "timestamp with time zone": tm.sqlTypesAlias + ".SqlTimeStamp",
"timestamptz": tm.sqlTypesAlias + ".SqlTime", "timestamptz": tm.sqlTypesAlias + ".SqlTimeStamp",
"date": tm.sqlTypesAlias + ".SqlDate", "date": tm.sqlTypesAlias + ".SqlDate",
"time": tm.sqlTypesAlias + ".SqlTime", "time": tm.sqlTypesAlias + ".SqlTime",
"time without time zone": tm.sqlTypesAlias + ".SqlTime", "time without time zone": tm.sqlTypesAlias + ".SqlTime",

View File

@@ -655,7 +655,7 @@ func TestTypeMapper_SQLTypeToGoType(t *testing.T) {
{"varchar", true, "string"}, {"varchar", true, "string"},
{"varchar", false, "sql_types.SqlString"}, {"varchar", false, "sql_types.SqlString"},
{"timestamp", true, "time.Time"}, {"timestamp", true, "time.Time"},
{"timestamp", false, "sql_types.SqlTime"}, {"timestamp", false, "sql_types.SqlTimeStamp"},
{"boolean", true, "bool"}, {"boolean", true, "bool"},
{"boolean", false, "sql_types.SqlBool"}, {"boolean", false, "sql_types.SqlBool"},
} }