Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16af529120 | ||
|
|
7fb343596a | ||
|
|
241bfc2302 | ||
|
|
92d5df9a64 | ||
|
|
b440d50b66 | ||
|
|
052d6f5fac |
@@ -222,6 +222,7 @@ jobs:
|
|||||||
PKGDIR="relspec_${PKGVER}_${GOARCH}"
|
PKGDIR="relspec_${PKGVER}_${GOARCH}"
|
||||||
mkdir -p "${PKGDIR}/DEBIAN"
|
mkdir -p "${PKGDIR}/DEBIAN"
|
||||||
mkdir -p "${PKGDIR}/usr/bin"
|
mkdir -p "${PKGDIR}/usr/bin"
|
||||||
|
chmod -R 0755 "${PKGDIR}"
|
||||||
|
|
||||||
install -m755 relspec "${PKGDIR}/usr/bin/relspec"
|
install -m755 relspec "${PKGDIR}/usr/bin/relspec"
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
printVersionHeader(os.Args[1:])
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+19
-3
@@ -13,6 +13,7 @@ var (
|
|||||||
version = "dev"
|
version = "dev"
|
||||||
buildDate = "unknown"
|
buildDate = "unknown"
|
||||||
prisma7 bool
|
prisma7 bool
|
||||||
|
noVersion bool
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -54,9 +55,6 @@ bidirectional conversion between various database schema formats.
|
|||||||
It reads database schemas from multiple sources (live databases, DBML,
|
It reads database schemas from multiple sources (live databases, DBML,
|
||||||
DCTX, DrawDB, etc.) and writes them to various formats (GORM, Bun,
|
DCTX, DrawDB, etc.) and writes them to various formats (GORM, Bun,
|
||||||
JSON, YAML, SQL, etc.).`,
|
JSON, YAML, SQL, etc.).`,
|
||||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
|
||||||
fmt.Printf("RelSpec %s (built: %s)\n\n", version, buildDate)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -72,4 +70,22 @@ func init() {
|
|||||||
rootCmd.AddCommand(versionCmd)
|
rootCmd.AddCommand(versionCmd)
|
||||||
rootCmd.AddCommand(reportCmd)
|
rootCmd.AddCommand(reportCmd)
|
||||||
rootCmd.PersistentFlags().BoolVar(&prisma7, "prisma7", false, "Use Prisma 7 generator conventions when reading/writing Prisma schemas")
|
rootCmd.PersistentFlags().BoolVar(&prisma7, "prisma7", false, "Use Prisma 7 generator conventions when reading/writing Prisma schemas")
|
||||||
|
rootCmd.PersistentFlags().BoolVar(&noVersion, "no-version", false, "Suppress the RelSpec version header")
|
||||||
|
}
|
||||||
|
|
||||||
|
// printVersionHeader prints the "RelSpec <version> (built: <date>)" banner
|
||||||
|
// that precedes all command output. It is invoked from main() before cobra
|
||||||
|
// parses/executes anything, so it runs even for --help and bare invocations.
|
||||||
|
// It is skipped when --no-version is present, or when the version subcommand
|
||||||
|
// is being run (which prints its own, more detailed output).
|
||||||
|
func printVersionHeader(args []string) {
|
||||||
|
for _, a := range args {
|
||||||
|
if a == "--no-version" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(args) > 0 && args[0] == "version" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("RelSpec %s (built: %s)\n\n", version, buildDate)
|
||||||
}
|
}
|
||||||
|
|||||||
+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.71
|
pkgver=1.0.73
|
||||||
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.71
|
Version: 1.0.73
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
+78
-23
@@ -434,6 +434,7 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
|||||||
var currentSchema string
|
var currentSchema string
|
||||||
var inIndexes bool
|
var inIndexes bool
|
||||||
var inTable bool
|
var inTable bool
|
||||||
|
var columnSeq uint
|
||||||
|
|
||||||
tableRegex := regexp.MustCompile(`^Table\s+(.+?)\s*{`)
|
tableRegex := regexp.MustCompile(`^Table\s+(.+?)\s*{`)
|
||||||
refRegex := regexp.MustCompile(`^Ref:\s+(.+)`)
|
refRegex := regexp.MustCompile(`^Ref:\s+(.+)`)
|
||||||
@@ -469,6 +470,7 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
|||||||
currentTable = models.InitTable(tableName, currentSchema)
|
currentTable = models.InitTable(tableName, currentSchema)
|
||||||
inTable = true
|
inTable = true
|
||||||
inIndexes = false
|
inIndexes = false
|
||||||
|
columnSeq = 0
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +499,17 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
|||||||
|
|
||||||
// Parse index definition
|
// Parse index definition
|
||||||
if inIndexes && currentTable != nil {
|
if inIndexes && currentTable != nil {
|
||||||
|
// A composite `[pk]` entry inside an Indexes block declares the
|
||||||
|
// table's primary key (DBML's way of expressing multi-column PKs
|
||||||
|
// that can't be attached to a single column). It must become a
|
||||||
|
// primary key constraint, not a plain index, or the PK is lost.
|
||||||
|
if indexLineHasPKAttr(line) {
|
||||||
|
if constraint := r.parsePrimaryKeyIndex(line, currentTable.Name, currentSchema); constraint != nil {
|
||||||
|
currentTable.Constraints[constraint.Name] = constraint
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
index := r.parseIndex(line, currentTable.Name, currentSchema)
|
index := r.parseIndex(line, currentTable.Name, currentSchema)
|
||||||
if index != nil {
|
if index != nil {
|
||||||
currentTable.Indexes[index.Name] = index
|
currentTable.Indexes[index.Name] = index
|
||||||
@@ -516,6 +529,8 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
|||||||
if inTable && !inIndexes && currentTable != nil {
|
if inTable && !inIndexes && currentTable != nil {
|
||||||
column, constraint := r.parseColumn(line, currentTable.Name, currentSchema)
|
column, constraint := r.parseColumn(line, currentTable.Name, currentSchema)
|
||||||
if column != nil {
|
if column != nil {
|
||||||
|
columnSeq++
|
||||||
|
column.Sequence = columnSeq
|
||||||
currentTable.Columns[column.Name] = column
|
currentTable.Columns[column.Name] = column
|
||||||
}
|
}
|
||||||
if constraint != nil {
|
if constraint != nil {
|
||||||
@@ -743,9 +758,10 @@ func stripWrappingQuotes(s string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseIndex parses a DBML index definition
|
// indexLineColumns extracts the column list from an Indexes-block entry,
|
||||||
func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
|
// e.g. "(col1, col2) [attrs]" or "columnname [attrs]", preserving
|
||||||
// Format: (columns) [attributes] OR columnname [attributes]
|
// declaration order.
|
||||||
|
func indexLineColumns(line string) []string {
|
||||||
var columns []string
|
var columns []string
|
||||||
|
|
||||||
// Find the attributes section to avoid parsing parentheses in notes/attributes
|
// Find the attributes section to avoid parsing parentheses in notes/attributes
|
||||||
@@ -776,6 +792,56 @@ func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexLineAttrs extracts and splits the bracketed attribute list of an
|
||||||
|
// Indexes-block entry, e.g. "[pk]" or "[unique, name: 'foo']".
|
||||||
|
func indexLineAttrs(line string) []string {
|
||||||
|
attrStart := strings.Index(line, "[")
|
||||||
|
attrEnd := strings.Index(line, "]")
|
||||||
|
if attrStart < 0 || attrEnd < 0 || attrStart >= attrEnd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var attrs []string
|
||||||
|
for _, attr := range strings.Split(line[attrStart+1:attrEnd], ",") {
|
||||||
|
attrs = append(attrs, strings.TrimSpace(attr))
|
||||||
|
}
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexLineHasPKAttr reports whether an Indexes-block entry carries a `pk`
|
||||||
|
// attribute, e.g. "(artifact_id, sha256) [pk]". DBML uses this form to
|
||||||
|
// declare composite primary keys that can't be attached to a single column.
|
||||||
|
func indexLineHasPKAttr(line string) bool {
|
||||||
|
for _, attr := range indexLineAttrs(line) {
|
||||||
|
if attr == "pk" || attr == "primary key" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePrimaryKeyIndex converts a composite `[pk]` entry from an Indexes
|
||||||
|
// block into a primary key constraint, preserving the declared column order.
|
||||||
|
func (r *Reader) parsePrimaryKeyIndex(line, tableName, schemaName string) *models.Constraint {
|
||||||
|
columns := indexLineColumns(line)
|
||||||
|
if len(columns) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
constraint := models.InitConstraint("pk_"+tableName, models.PrimaryKeyConstraint)
|
||||||
|
constraint.Schema = schemaName
|
||||||
|
constraint.Table = tableName
|
||||||
|
constraint.Columns = columns
|
||||||
|
return constraint
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseIndex parses a DBML index definition
|
||||||
|
func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
|
||||||
|
// Format: (columns) [attributes] OR columnname [attributes]
|
||||||
|
columns := indexLineColumns(line)
|
||||||
if len(columns) == 0 {
|
if len(columns) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -786,26 +852,15 @@ func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
|
|||||||
index.Columns = columns
|
index.Columns = columns
|
||||||
|
|
||||||
// Parse attributes
|
// Parse attributes
|
||||||
if strings.Contains(line, "[") && strings.Contains(line, "]") {
|
for _, attr := range indexLineAttrs(line) {
|
||||||
attrStart := strings.Index(line, "[")
|
if attr == "unique" {
|
||||||
attrEnd := strings.Index(line, "]")
|
index.Unique = true
|
||||||
if attrStart < attrEnd {
|
} else if strings.HasPrefix(attr, "name:") {
|
||||||
attrs := line[attrStart+1 : attrEnd]
|
name := strings.TrimSpace(strings.TrimPrefix(attr, "name:"))
|
||||||
attrList := strings.Split(attrs, ",")
|
index.Name = strings.Trim(name, "'\"")
|
||||||
|
} else if strings.HasPrefix(attr, "type:") {
|
||||||
for _, attr := range attrList {
|
indexType := strings.TrimSpace(strings.TrimPrefix(attr, "type:"))
|
||||||
attr = strings.TrimSpace(attr)
|
index.Type = strings.Trim(indexType, "'\"")
|
||||||
|
|
||||||
if attr == "unique" {
|
|
||||||
index.Unique = true
|
|
||||||
} else if strings.HasPrefix(attr, "name:") {
|
|
||||||
name := strings.TrimSpace(strings.TrimPrefix(attr, "name:"))
|
|
||||||
index.Name = strings.Trim(name, "'\"")
|
|
||||||
} else if strings.HasPrefix(attr, "type:") {
|
|
||||||
indexType := strings.TrimSpace(strings.TrimPrefix(attr, "type:"))
|
|
||||||
index.Type = strings.Trim(indexType, "'\"")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -932,3 +932,97 @@ func TestHasCommentedRefs(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReader_CompositePKIndex verifies that a composite `[pk]` entry inside
|
||||||
|
// an Indexes block is turned into a primary key constraint, in declaration
|
||||||
|
// order, rather than being silently dropped.
|
||||||
|
func TestReader_CompositePKIndex(t *testing.T) {
|
||||||
|
dbmlContent := `Table artifact_blob {
|
||||||
|
artifact_id integer [not null]
|
||||||
|
sha256 text [not null]
|
||||||
|
size integer
|
||||||
|
|
||||||
|
Indexes {
|
||||||
|
(artifact_id, sha256) [pk]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "composite_pk.dbml")
|
||||||
|
if err := os.WriteFile(path, []byte(dbmlContent), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := NewReader(&readers.ReaderOptions{FilePath: path})
|
||||||
|
db, err := reader.ReadDatabase()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDatabase() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
table := db.Schemas[0].Tables[0]
|
||||||
|
|
||||||
|
var pk *models.Constraint
|
||||||
|
for _, c := range table.Constraints {
|
||||||
|
if c.Type == models.PrimaryKeyConstraint {
|
||||||
|
pk = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pk == nil {
|
||||||
|
t.Fatal("expected a primary key constraint, got none")
|
||||||
|
}
|
||||||
|
want := []string{"artifact_id", "sha256"}
|
||||||
|
if len(pk.Columns) != len(want) {
|
||||||
|
t.Fatalf("expected PK columns %v, got %v", want, pk.Columns)
|
||||||
|
}
|
||||||
|
for i, col := range want {
|
||||||
|
if pk.Columns[i] != col {
|
||||||
|
t.Errorf("PK column[%d] = %q, want %q (order must match declaration)", i, pk.Columns[i], col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No plain index should be emitted for the pk-only entry.
|
||||||
|
if len(table.Indexes) != 0 {
|
||||||
|
t.Errorf("expected no plain indexes from a [pk] Indexes entry, got %v", table.Indexes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReader_ColumnPKOrderPreserved verifies that composite primary keys
|
||||||
|
// declared via column-level [pk] attributes keep declaration order (via
|
||||||
|
// Column.Sequence) instead of falling back to alphabetical sorting.
|
||||||
|
func TestReader_ColumnPKOrderPreserved(t *testing.T) {
|
||||||
|
dbmlContent := `Table snapshot_artifact {
|
||||||
|
snapshot_id integer [pk, not null]
|
||||||
|
artifact_id integer [pk, not null]
|
||||||
|
}
|
||||||
|
`
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "column_pk_order.dbml")
|
||||||
|
if err := os.WriteFile(path, []byte(dbmlContent), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := NewReader(&readers.ReaderOptions{FilePath: path})
|
||||||
|
db, err := reader.ReadDatabase()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDatabase() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
table := db.Schemas[0].Tables[0]
|
||||||
|
|
||||||
|
snapshotCol, ok := table.Columns["snapshot_id"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("column 'snapshot_id' not found")
|
||||||
|
}
|
||||||
|
artifactCol, ok := table.Columns["artifact_id"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("column 'artifact_id' not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshotCol.Sequence == 0 || artifactCol.Sequence == 0 {
|
||||||
|
t.Fatalf("expected non-zero Sequence values, got snapshot_id=%d artifact_id=%d", snapshotCol.Sequence, artifactCol.Sequence)
|
||||||
|
}
|
||||||
|
if snapshotCol.Sequence >= artifactCol.Sequence {
|
||||||
|
t.Errorf("expected snapshot_id (declared first) to have a lower Sequence than artifact_id, got %d >= %d", snapshotCol.Sequence, artifactCol.Sequence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -356,4 +356,3 @@ func (w *Writer) writeCheckConstraints(schema string, table *models.Table) error
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user