fix(dbml): honor composite [pk] in Indexes blocks, preserve column order
A composite [pk] entry inside an Indexes block (e.g. (a, b) [pk]) was silently dropped: models.Index has no way to represent a primary key, so the attribute was parsed and ignored, producing neither a PK nor a meaningful index. It's now converted into a PrimaryKeyConstraint. Also, Column.Sequence was never set by the DBML reader, so composite PKs assembled from column-level [pk] attributes fell back to alphabetical Name sorting instead of declaration order. Columns now get a per-table sequence counter reflecting the order they were declared.
This commit is contained in:
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user