Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb104ea084 | |||
| 837160b77a | |||
| ed7130bba8 | |||
| 4ca1810d07 |
@@ -42,6 +42,11 @@ relspec convert --from pgsql --from-conn "postgres://..." --to sqlite --to-path
|
||||
relspec convert --from json --from-list "a.json,b.json" --to yaml --to-path merged.yaml
|
||||
```
|
||||
|
||||
PostgreSQL connections opened by relspec set `application_name` by default to
|
||||
`relspecgo/<version>` (with component suffixes internally, e.g. readers/writers).
|
||||
If you need a custom value, provide `application_name` explicitly in the connection
|
||||
string query parameters.
|
||||
|
||||
### `merge` — Additive schema merge (never modifies existing items)
|
||||
|
||||
```bash
|
||||
|
||||
85
pkg/pgsql/connection.go
Normal file
85
pkg/pgsql/connection.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultApplicationPrefix = "relspecgo"
|
||||
postgresIdentifierMaxLen = 63
|
||||
)
|
||||
|
||||
// BuildApplicationName returns a PostgreSQL application_name in the form:
|
||||
// relspecgo/<version>[:<component>]
|
||||
func BuildApplicationName(component string) string {
|
||||
appName := fmt.Sprintf("%s/%s", defaultApplicationPrefix, relspecVersion())
|
||||
component = strings.TrimSpace(component)
|
||||
if component != "" {
|
||||
appName = appName + ":" + component
|
||||
}
|
||||
if len(appName) > postgresIdentifierMaxLen {
|
||||
appName = appName[:postgresIdentifierMaxLen]
|
||||
}
|
||||
return appName
|
||||
}
|
||||
|
||||
// ParseConfigWithApplicationName parses a connection string and applies a default
|
||||
// application_name when one is not explicitly provided by the caller.
|
||||
func ParseConfigWithApplicationName(connString, component string) (*pgx.ConnConfig, error) {
|
||||
cfg, err := pgx.ParseConfig(connString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.RuntimeParams == nil {
|
||||
cfg.RuntimeParams = map[string]string{}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cfg.RuntimeParams["application_name"]) == "" {
|
||||
cfg.RuntimeParams["application_name"] = BuildApplicationName(component)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Connect establishes a PostgreSQL connection with a default relspec
|
||||
// application_name when the caller does not provide one in the DSN.
|
||||
func Connect(ctx context.Context, connString, component string) (*pgx.Conn, error) {
|
||||
cfg, err := ParseConfigWithApplicationName(connString, component)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pgx.ConnectConfig(ctx, cfg)
|
||||
}
|
||||
|
||||
func relspecVersion() string {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return "dev"
|
||||
}
|
||||
|
||||
version := strings.TrimSpace(info.Main.Version)
|
||||
if version != "" && version != "(devel)" {
|
||||
return version
|
||||
}
|
||||
|
||||
for _, setting := range info.Settings {
|
||||
if setting.Key == "vcs.revision" {
|
||||
revision := strings.TrimSpace(setting.Value)
|
||||
if len(revision) >= 7 {
|
||||
return revision[:7]
|
||||
}
|
||||
if revision != "" {
|
||||
return revision
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "dev"
|
||||
}
|
||||
53
pkg/pgsql/connection_test.go
Normal file
53
pkg/pgsql/connection_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildApplicationName_IncludesVersion(t *testing.T) {
|
||||
got := BuildApplicationName("")
|
||||
if !strings.HasPrefix(got, "relspecgo/") {
|
||||
t.Fatalf("BuildApplicationName() = %q, expected prefix relspecgo/", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildApplicationName_IncludesComponent(t *testing.T) {
|
||||
got := BuildApplicationName("reader-pgsql")
|
||||
if !strings.Contains(got, ":reader-pgsql") {
|
||||
t.Fatalf("BuildApplicationName(component) = %q, expected component suffix", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildApplicationName_RespectsPostgresLengthLimit(t *testing.T) {
|
||||
got := BuildApplicationName(strings.Repeat("x", 200))
|
||||
if len(got) > 63 {
|
||||
t.Fatalf("BuildApplicationName() length = %d, expected <= 63", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigWithApplicationName_AddsWhenMissing(t *testing.T) {
|
||||
cfg, err := ParseConfigWithApplicationName("postgres://user:pass@localhost:5432/db", "reader-pgsql")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfigWithApplicationName() error = %v", err)
|
||||
}
|
||||
|
||||
appName := cfg.RuntimeParams["application_name"]
|
||||
if appName == "" {
|
||||
t.Fatal("expected application_name to be set")
|
||||
}
|
||||
if !strings.HasPrefix(appName, "relspecgo/") {
|
||||
t.Fatalf("application_name = %q, expected relspecgo/<version> prefix", appName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigWithApplicationName_PreservesExplicitValue(t *testing.T) {
|
||||
cfg, err := ParseConfigWithApplicationName("postgres://user:pass@localhost:5432/db?application_name=custom-app", "reader-pgsql")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfigWithApplicationName() error = %v", err)
|
||||
}
|
||||
|
||||
if got := cfg.RuntimeParams["application_name"]; got != "custom-app" {
|
||||
t.Fatalf("application_name = %q, expected %q", got, "custom-app")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package pgsql
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TypeSpec describes PostgreSQL type capabilities used by parsers/writers.
|
||||
type TypeSpec struct {
|
||||
@@ -106,9 +109,9 @@ var postgresBaseTypes = map[string]TypeSpec{
|
||||
"ltree": {},
|
||||
"lquery": {},
|
||||
"ltxtquery": {},
|
||||
"vector": {SupportsLength: true}, // pgvector: vector(dim)
|
||||
"halfvec": {SupportsLength: true}, // pgvector: halfvec(dim)
|
||||
"sparsevec": {SupportsLength: true}, // pgvector: sparsevec(dim)
|
||||
"vector": {}, // pgvector: keep explicit modifier form (vector(dim))
|
||||
"halfvec": {}, // pgvector: keep explicit modifier form (halfvec(dim))
|
||||
"sparsevec": {}, // pgvector: keep explicit modifier form (sparsevec(dim))
|
||||
}
|
||||
|
||||
var postgresTypeAliases = map[string]string{
|
||||
@@ -148,6 +151,7 @@ func GetPostgresBaseTypes() []string {
|
||||
for t := range postgresBaseTypes {
|
||||
result = append(result, t)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestPostgresTypeRegistry_TypeParsingAndCapabilities(t *testing.T) {
|
||||
wantBase: "vector",
|
||||
wantCanonicalBase: "vector",
|
||||
wantKnown: true,
|
||||
wantLength: true,
|
||||
wantLength: false,
|
||||
},
|
||||
{
|
||||
input: "numeric(10,2)",
|
||||
|
||||
@@ -711,6 +711,7 @@ func (r *Reader) parseTypeWithLength(typeStr string) (baseType string, length in
|
||||
rawBaseType := strings.TrimSpace(matches[1])
|
||||
if pgsql.SupportsLength(rawBaseType) {
|
||||
if _, err := fmt.Sscanf(matches[2], "%d", &length); err == nil {
|
||||
baseType = pgsql.CanonicalizeBaseType(rawBaseType)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,9 +367,9 @@ func TestParseTypeWithLength_PreservesExplicitTypeModifiers(t *testing.T) {
|
||||
wantType string
|
||||
wantLength int
|
||||
}{
|
||||
{"varchar(255)", "varchar(255)", 255},
|
||||
{"character varying(120)", "character varying(120)", 120},
|
||||
{"vector(1536)", "vector(1536)", 1536},
|
||||
{"varchar(255)", "varchar", 255},
|
||||
{"character varying(120)", "character varying", 120},
|
||||
{"vector(1536)", "vector(1536)", 0},
|
||||
{"numeric(10,2)", "numeric(10,2)", 0},
|
||||
}
|
||||
|
||||
|
||||
@@ -664,7 +664,7 @@ func (r *Reader) parseColumn(line, tableName, schemaName string) (*models.Column
|
||||
return column, constraint
|
||||
}
|
||||
|
||||
func splitInlineComment(line string) (string, string) {
|
||||
func splitInlineComment(line string) (content string, inlineComment string) {
|
||||
commentStart := strings.Index(line, "//")
|
||||
if commentStart == -1 {
|
||||
return line, ""
|
||||
@@ -673,7 +673,7 @@ func splitInlineComment(line string) (string, string) {
|
||||
return strings.TrimSpace(line[:commentStart]), strings.TrimSpace(line[commentStart+2:])
|
||||
}
|
||||
|
||||
func splitColumnSignatureAndAttrs(line string) (string, string) {
|
||||
func splitColumnSignatureAndAttrs(line string) (signature string, attrs string) {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || !strings.HasSuffix(trimmed, "]") {
|
||||
return trimmed, ""
|
||||
@@ -699,7 +699,7 @@ func splitColumnSignatureAndAttrs(line string) (string, string) {
|
||||
return trimmed, ""
|
||||
}
|
||||
|
||||
func parseColumnSignature(signature string) (string, string, bool) {
|
||||
func parseColumnSignature(signature string) (columnName string, columnType string, ok bool) {
|
||||
signature = strings.TrimSpace(signature)
|
||||
if signature == "" {
|
||||
return "", "", false
|
||||
@@ -726,8 +726,8 @@ func parseColumnSignature(signature string) (string, string, bool) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
columnName := stripQuotes(strings.TrimSpace(signature[:splitAt]))
|
||||
columnType := stripWrappingQuotes(strings.TrimSpace(signature[splitAt:]))
|
||||
columnName = stripQuotes(strings.TrimSpace(signature[:splitAt]))
|
||||
columnType = stripWrappingQuotes(strings.TrimSpace(signature[splitAt:]))
|
||||
if columnName == "" || columnType == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ func TestConvertToColumn_PreservesExplicitTypeModifiers(t *testing.T) {
|
||||
name: "custom vector modifier",
|
||||
fieldType: "vector(1536)",
|
||||
wantType: "vector(1536)",
|
||||
wantLength: 1536,
|
||||
wantLength: 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -804,6 +804,7 @@ func (r *Reader) parseTypeWithLength(typeStr string) (baseType string, length in
|
||||
// This avoids converting custom modifiers like vector(1536) into Length.
|
||||
if pgsql.SupportsLength(rawBaseType) && !strings.Contains(parens, ",") {
|
||||
if _, err := fmt.Sscanf(parens, "%d", &length); err == nil {
|
||||
baseType = pgsql.CanonicalizeBaseType(rawBaseType)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,9 +374,9 @@ func TestParseTypeWithLength_PreservesExplicitTypeModifiers(t *testing.T) {
|
||||
wantType string
|
||||
wantLength int
|
||||
}{
|
||||
{"varchar(255)", "varchar(255)", 255},
|
||||
{"character varying(120)", "character varying(120)", 120},
|
||||
{"vector(1536)", "vector(1536)", 1536},
|
||||
{"varchar(255)", "varchar", 255},
|
||||
{"character varying(120)", "character varying", 120},
|
||||
{"vector(1536)", "vector(1536)", 0},
|
||||
{"numeric(10,2)", "numeric(10,2)", 0},
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,10 @@ postgres://user@localhost/mydb?sslmode=disable
|
||||
postgres://user:pass@db.example.com:5432/production?sslmode=require
|
||||
```
|
||||
|
||||
By default, relspec sets `application_name` to `relspecgo/<version>` for PostgreSQL
|
||||
sessions so they are identifiable in `pg_stat_activity`. If you provide
|
||||
`application_name` in the connection string, your explicit value is preserved.
|
||||
|
||||
## Extracted Information
|
||||
|
||||
### Tables
|
||||
|
||||
@@ -244,7 +244,7 @@ func (r *Reader) ReadTable() (*models.Table, error) {
|
||||
|
||||
// connect establishes a connection to the PostgreSQL database
|
||||
func (r *Reader) connect() error {
|
||||
conn, err := pgx.Connect(r.ctx, r.options.ConnectionString)
|
||||
conn, err := pgsql.Connect(r.ctx, r.options.ConnectionString, "reader-pgsql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -155,8 +156,15 @@ func (w *Writer) mapTableFields(table *models.Table) models.DCTXTable {
|
||||
},
|
||||
}
|
||||
|
||||
columnNames := make([]string, 0, len(table.Columns))
|
||||
for name := range table.Columns {
|
||||
columnNames = append(columnNames, name)
|
||||
}
|
||||
sort.Strings(columnNames)
|
||||
|
||||
i := 0
|
||||
for _, column := range table.Columns {
|
||||
for _, colName := range columnNames {
|
||||
column := table.Columns[colName]
|
||||
dctxTable.Fields[i] = w.mapField(column)
|
||||
i++
|
||||
}
|
||||
@@ -165,12 +173,27 @@ func (w *Writer) mapTableFields(table *models.Table) models.DCTXTable {
|
||||
}
|
||||
|
||||
func (w *Writer) mapTableKeys(table *models.Table) []models.DCTXKey {
|
||||
keys := make([]models.DCTXKey, len(table.Indexes))
|
||||
i := 0
|
||||
indexes := make([]*models.Index, 0, len(table.Indexes))
|
||||
for _, index := range table.Indexes {
|
||||
keys[i] = w.mapKey(index, table)
|
||||
i++
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
|
||||
// Stable ordering for deterministic output and test reproducibility:
|
||||
// primary keys first, then lexicographic by index name.
|
||||
sort.Slice(indexes, func(i, j int) bool {
|
||||
iPrimary := strings.HasSuffix(indexes[i].Name, "_pkey")
|
||||
jPrimary := strings.HasSuffix(indexes[j].Name, "_pkey")
|
||||
if iPrimary != jPrimary {
|
||||
return iPrimary
|
||||
}
|
||||
return indexes[i].Name < indexes[j].Name
|
||||
})
|
||||
|
||||
keys := make([]models.DCTXKey, len(indexes))
|
||||
for i, index := range indexes {
|
||||
keys[i] = w.mapKey(index, table)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
@@ -1353,7 +1351,7 @@ func (w *Writer) executeDatabaseSQL(db *models.Database, connString string) erro
|
||||
|
||||
// Connect to database
|
||||
ctx := context.Background()
|
||||
conn, err := pgx.Connect(ctx, connString)
|
||||
conn, err := pgsql.Connect(ctx, connString, "writer-pgsql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
@@ -42,7 +43,7 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
|
||||
// Connect to database
|
||||
ctx := context.Background()
|
||||
conn, err := pgx.Connect(ctx, connString)
|
||||
conn, err := pgsql.Connect(ctx, connString, "writer-sqlexec")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
@@ -72,7 +73,7 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
|
||||
// Connect to database
|
||||
ctx := context.Background()
|
||||
conn, err := pgx.Connect(ctx, connString)
|
||||
conn, err := pgsql.Connect(ctx, connString, "writer-sqlexec")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user