Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 316d9b0e7f | |||
| 17ae8e050a | |||
| f0410221d8 | |||
| 1c217b546c | |||
| 5c31deb630 | |||
| c2def00bcf | |||
| 784dc1f0da | |||
| 7d93bee4bd |
@@ -393,7 +393,7 @@ func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaF
|
|||||||
|
|
||||||
writerOpts := newWriterOptions(outputPath, packageName, flattenSchema, nullableTypes, continueOnError)
|
writerOpts := newWriterOptions(outputPath, packageName, flattenSchema, nullableTypes, continueOnError)
|
||||||
if extraFields != "" {
|
if extraFields != "" {
|
||||||
if strings.ToLower(dbType) != "bun" {
|
if !strings.EqualFold(dbType, "bun") {
|
||||||
return fmt.Errorf("--extra-fields is only supported for Bun output")
|
return fmt.Errorf("--extra-fields is only supported for Bun output")
|
||||||
}
|
}
|
||||||
extraFieldsJSON, err := os.ReadFile(extraFields)
|
extraFieldsJSON, err := os.ReadFile(extraFields)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
||||||
pkgname=relspec
|
pkgname=relspec
|
||||||
pkgver=1.0.62
|
pkgver=1.0.64
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs."
|
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')
|
arch=('x86_64' 'aarch64')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Name: relspec
|
Name: relspec
|
||||||
Version: 1.0.62
|
Version: 1.0.64
|
||||||
Release: 1%{?dist}
|
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.
|
Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func ProcessEmbedDirectives(sqlPath, sql string) (string, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func embedDirectiveLiteral(sqlPath, raw string, directiveNumber int) (string, string, error) {
|
func embedDirectiveLiteral(sqlPath, raw string, directiveNumber int) (literal, placeholder string, err error) {
|
||||||
attrs, err := parseEmbedAttrs(raw)
|
attrs, err := parseEmbedAttrs(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
|
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
|
||||||
|
|||||||
@@ -29,15 +29,14 @@ const pgCastMarker = "\x00PGCAST\x00"
|
|||||||
// A placeholder that appears more than once maps to the same $N. An unknown
|
// A placeholder that appears more than once maps to the same $N. An unknown
|
||||||
// placeholder (not built-in and not in staticParams) returns an error.
|
// placeholder (not built-in and not in staticParams) returns an error.
|
||||||
// PostgreSQL cast syntax (::type) is left untouched.
|
// PostgreSQL cast syntax (::type) is left untouched.
|
||||||
func BuildQuery(call string, fileBytes []byte, filename string, staticParams map[string]string) (string, []any, error) {
|
func BuildQuery(call string, fileBytes []byte, filename string, staticParams map[string]string) (query string, args []any, err error) {
|
||||||
// Protect :: casts before running the placeholder regex.
|
// Protect :: casts before running the placeholder regex.
|
||||||
protected := strings.ReplaceAll(call, "::", pgCastMarker)
|
protected := strings.ReplaceAll(call, "::", pgCastMarker)
|
||||||
|
|
||||||
paramIndex := map[string]int{} // name → 1-based position
|
paramIndex := map[string]int{} // name → 1-based position
|
||||||
var args []any
|
|
||||||
var firstErr error
|
var firstErr error
|
||||||
|
|
||||||
result := namedPlaceholder.ReplaceAllStringFunc(protected, func(match string) string {
|
query = namedPlaceholder.ReplaceAllStringFunc(protected, func(match string) string {
|
||||||
if firstErr != nil {
|
if firstErr != nil {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
@@ -78,9 +77,9 @@ func BuildQuery(call string, fileBytes []byte, filename string, staticParams map
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Restore :: casts.
|
// Restore :: casts.
|
||||||
result = strings.ReplaceAll(result, pgCastMarker, "::")
|
query = strings.ReplaceAll(query, pgCastMarker, "::")
|
||||||
|
|
||||||
return result, args, nil
|
return query, args, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteItem reads the asset file referenced by item.Entry.File (which is the
|
// ExecuteItem reads the asset file referenced by item.Entry.File (which is the
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package reflectutil
|
package reflectutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,7 +136,7 @@ func MapKeys(i interface{}) []interface{} {
|
|||||||
return []interface{}{}
|
return []interface{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
keys := v.MapKeys()
|
keys := sortedMapKeys(v)
|
||||||
result := make([]interface{}, len(keys))
|
result := make([]interface{}, len(keys))
|
||||||
for i, key := range keys {
|
for i, key := range keys {
|
||||||
result[i] = key.Interface()
|
result[i] = key.Interface()
|
||||||
@@ -155,14 +157,39 @@ func MapValues(i interface{}) []interface{} {
|
|||||||
return []interface{}{}
|
return []interface{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]interface{}, 0, v.Len())
|
keys := sortedMapKeys(v)
|
||||||
iter := v.MapRange()
|
result := make([]interface{}, 0, len(keys))
|
||||||
for iter.Next() {
|
for _, key := range keys {
|
||||||
result = append(result, iter.Value().Interface())
|
result = append(result, v.MapIndex(key).Interface())
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sortedMapKeys(v reflect.Value) []reflect.Value {
|
||||||
|
keys := v.MapKeys()
|
||||||
|
sort.SliceStable(keys, func(i, j int) bool {
|
||||||
|
return mapKeyLess(keys[i], keys[j])
|
||||||
|
})
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapKeyLess(a, b reflect.Value) bool {
|
||||||
|
switch a.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
return a.String() < b.String()
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return a.Int() < b.Int()
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||||
|
return a.Uint() < b.Uint()
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
return a.Float() < b.Float()
|
||||||
|
case reflect.Bool:
|
||||||
|
return !a.Bool() && b.Bool()
|
||||||
|
default:
|
||||||
|
return fmt.Sprint(a.Interface()) < fmt.Sprint(b.Interface())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MapGet safely gets a value from a map by key
|
// MapGet safely gets a value from a map by key
|
||||||
// Returns nil if key doesn't exist or not a map
|
// Returns nil if key doesn't exist or not a map
|
||||||
func MapGet(m interface{}, key interface{}) interface{} {
|
func MapGet(m interface{}, key interface{}) interface{} {
|
||||||
|
|||||||
@@ -186,6 +186,40 @@ func (tm *TypeMapper) bunGoType(sqlType string) string {
|
|||||||
return tm.sqlTypesAlias + ".SqlString"
|
return tm.sqlTypesAlias + ".SqlString"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pgArrayInternalTypeName returns PostgreSQL's internal array type name
|
||||||
|
// (e.g. "_text" for text[]) for the given canonical base element type.
|
||||||
|
//
|
||||||
|
// This is used instead of the "text[]" spelling in the sqltypes-style bun
|
||||||
|
// tag: bun's pgdialect unconditionally overrides Field.Scan/Append with its
|
||||||
|
// own array handling whenever the tag's "type:" value ends in "[]" (see
|
||||||
|
// pgdialect.Dialect.onField), which clobbers the sql.Scanner/driver.Valuer
|
||||||
|
// implemented on the SqlXxxArray wrapper types and causes
|
||||||
|
// "bun: Scan(unsupported sqltypes.SqlXxxArray)" errors at query time. The
|
||||||
|
// underscore-prefixed internal name is a real, DDL-valid PostgreSQL type
|
||||||
|
// name that doesn't end in "[]", so it sidesteps the override.
|
||||||
|
func (tm *TypeMapper) pgArrayInternalTypeName(baseElemType string) string {
|
||||||
|
typeMap := map[string]string{
|
||||||
|
"text": "_text", "varchar": "_varchar",
|
||||||
|
"char": "_bpchar", "character": "_bpchar", "bpchar": "_bpchar",
|
||||||
|
"citext": "_citext",
|
||||||
|
"inet": "_inet", "cidr": "_cidr", "macaddr": "_macaddr",
|
||||||
|
"json": "_json", "jsonb": "_jsonb",
|
||||||
|
"integer": "_int4", "int": "_int4", "int4": "_int4", "serial": "_int4",
|
||||||
|
"smallint": "_int2", "int2": "_int2", "smallserial": "_int2",
|
||||||
|
"bigint": "_int8", "int8": "_int8", "bigserial": "_int8",
|
||||||
|
"real": "_float4", "float4": "_float4",
|
||||||
|
"double precision": "_float8", "float8": "_float8",
|
||||||
|
"numeric": "_numeric", "decimal": "_numeric",
|
||||||
|
"money": "_money",
|
||||||
|
"boolean": "_bool", "bool": "_bool",
|
||||||
|
"uuid": "_uuid",
|
||||||
|
}
|
||||||
|
if pgType, ok := typeMap[baseElemType]; ok {
|
||||||
|
return pgType
|
||||||
|
}
|
||||||
|
return "_text"
|
||||||
|
}
|
||||||
|
|
||||||
// arrayGoType returns the Go type for a PostgreSQL array column.
|
// arrayGoType returns the Go type for a PostgreSQL array column.
|
||||||
// The baseElemType is the canonical base type (e.g. "text", "integer").
|
// The baseElemType is the canonical base type (e.g. "text", "integer").
|
||||||
func (tm *TypeMapper) arrayGoType(baseElemType string) string {
|
func (tm *TypeMapper) arrayGoType(baseElemType string) string {
|
||||||
@@ -360,6 +394,9 @@ func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) st
|
|||||||
typeStr = fmt.Sprintf("%s(%d)", typeStr, column.Precision)
|
typeStr = fmt.Sprintf("%s(%d)", typeStr, column.Precision)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if isArray && tm.typeStyle == writers.NullableTypeSqlTypes {
|
||||||
|
typeStr = tm.pgArrayInternalTypeName(tm.extractBaseType(typeStr))
|
||||||
|
}
|
||||||
parts = append(parts, fmt.Sprintf("type:%s", typeStr))
|
parts = append(parts, fmt.Sprintf("type:%s", typeStr))
|
||||||
if isArray && tm.typeStyle == writers.NullableTypeStdlib {
|
if isArray && tm.typeStyle == writers.NullableTypeStdlib {
|
||||||
parts = append(parts, "array")
|
parts = append(parts, "array")
|
||||||
|
|||||||
@@ -827,9 +827,45 @@ func TestTypeMapper_BuildBunTag(t *testing.T) {
|
|||||||
t.Errorf("BuildBunTag() = %q, missing %q", result, part)
|
t.Errorf("BuildBunTag() = %q, missing %q", result, part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// sqltypes mode must NOT add "array" — SqlXxxArray uses sql.Scanner
|
// baselib mode must NOT add "array" — the Go type is already a
|
||||||
|
// real slice ([]string, []int32, ...), which bun's pgdialect
|
||||||
|
// scans natively without the explicit "array" tag option.
|
||||||
if strings.Contains(result, ",array,") || strings.HasSuffix(result, ",array,") {
|
if strings.Contains(result, ",array,") || strings.HasSuffix(result, ",array,") {
|
||||||
t.Errorf("BuildBunTag() = %q, must not contain 'array' in sqltypes mode", result)
|
t.Errorf("BuildBunTag() = %q, must not contain 'array' in baselib mode", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTypeMapper_BuildBunTag_SqlTypesArrayUsesInternalTypeName verifies that
|
||||||
|
// array columns in sqltypes mode never produce a "[]"-suffixed "type:" tag.
|
||||||
|
// bun's pgdialect unconditionally overrides Field.Scan/Append with its own
|
||||||
|
// (slice-only) array handling whenever the tag's "type:" value ends in "[]",
|
||||||
|
// which clobbers the sql.Scanner/driver.Valuer implemented on the
|
||||||
|
// SqlXxxArray wrapper types and produces
|
||||||
|
// "bun: Scan(unsupported sqltypes.SqlXxxArray)" at query time.
|
||||||
|
func TestTypeMapper_BuildBunTag_SqlTypesArrayUsesInternalTypeName(t *testing.T) {
|
||||||
|
mapper := NewTypeMapper(writers.NullableTypeSqlTypes)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
column *models.Column
|
||||||
|
wantSubstr string
|
||||||
|
}{
|
||||||
|
{name: "text array", column: &models.Column{Name: "tags", Type: "text[]"}, wantSubstr: "type:_text,"},
|
||||||
|
{name: "varchar array", column: &models.Column{Name: "labels", Type: "varchar[]"}, wantSubstr: "type:_varchar,"},
|
||||||
|
{name: "integer array", column: &models.Column{Name: "scores", Type: "integer[]", NotNull: true}, wantSubstr: "type:_int4,"},
|
||||||
|
{name: "boolean array", column: &models.Column{Name: "flags", Type: "boolean[]"}, wantSubstr: "type:_bool,"},
|
||||||
|
{name: "uuid array", column: &models.Column{Name: "ids", Type: "uuid[]"}, wantSubstr: "type:_uuid,"},
|
||||||
|
}
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := mapper.BuildBunTag(tt.column, nil)
|
||||||
|
if !strings.Contains(result, tt.wantSubstr) {
|
||||||
|
t.Errorf("BuildBunTag() = %q, missing %q", result, tt.wantSubstr)
|
||||||
|
}
|
||||||
|
if strings.Contains(result, "[]") {
|
||||||
|
t.Errorf("BuildBunTag() = %q, must not use a \"[]\"-suffixed type in sqltypes mode", result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package template
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriterTableIndexValuesDeterministic(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
templatePath := filepath.Join(dir, "indexes.tmpl")
|
||||||
|
outputDir := filepath.Join(dir, "out")
|
||||||
|
outputPath := filepath.Join(outputDir, "accounts.txt")
|
||||||
|
templateBody := "{{range values .Table.Indexes}}{{.Name}}:{{join .Columns \",\"}}\n{{end}}"
|
||||||
|
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
t.Fatalf("create output dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(templatePath, []byte(templateBody), 0644); err != nil {
|
||||||
|
t.Fatalf("write template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db := databaseWithMultipleIndexes()
|
||||||
|
var first []byte
|
||||||
|
const runs = 100
|
||||||
|
for i := 0; i < runs; i++ {
|
||||||
|
writer, err := NewWriter(&writers.WriterOptions{
|
||||||
|
OutputPath: outputDir,
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"template_path": templatePath,
|
||||||
|
"mode": string(TableMode),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writer.WriteDatabase(db); err != nil {
|
||||||
|
t.Fatalf("write database run %d: %v", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read output run %d: %v", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
first = got
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(got) != string(first) {
|
||||||
|
t.Fatalf("run %d output differed from first run\nfirst:\n%s\nrun %d:\n%s", i, first, i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
want := strings.Join([]string{
|
||||||
|
"idx_accounts_email:email",
|
||||||
|
"idx_accounts_last_login:last_login",
|
||||||
|
"idx_accounts_name:name",
|
||||||
|
"idx_accounts_status:status",
|
||||||
|
"idx_accounts_tenant:tenant_id",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
if string(first) != want {
|
||||||
|
t.Fatalf("unexpected index order\nwant:\n%s\ngot:\n%s", want, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func databaseWithMultipleIndexes() *models.Database {
|
||||||
|
db := models.InitDatabase("test")
|
||||||
|
schema := models.InitSchema("public")
|
||||||
|
table := models.InitTable("accounts", "public")
|
||||||
|
|
||||||
|
table.Indexes["idx_accounts_status"] = &models.Index{Name: "idx_accounts_status", Table: table.Name, Schema: schema.Name, Columns: []string{"status"}}
|
||||||
|
table.Indexes["idx_accounts_email"] = &models.Index{Name: "idx_accounts_email", Table: table.Name, Schema: schema.Name, Columns: []string{"email"}}
|
||||||
|
table.Indexes["idx_accounts_tenant"] = &models.Index{Name: "idx_accounts_tenant", Table: table.Name, Schema: schema.Name, Columns: []string{"tenant_id"}}
|
||||||
|
table.Indexes["idx_accounts_name"] = &models.Index{Name: "idx_accounts_name", Table: table.Name, Schema: schema.Name, Columns: []string{"name"}}
|
||||||
|
table.Indexes["idx_accounts_last_login"] = &models.Index{Name: "idx_accounts_last_login", Table: table.Name, Schema: schema.Name, Columns: []string{"last_login"}}
|
||||||
|
|
||||||
|
schema.Tables = append(schema.Tables, table)
|
||||||
|
db.Schemas = append(db.Schemas, schema)
|
||||||
|
return db
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user