From 58e46e5b596bce71023d46757502435524f4082d Mon Sep 17 00:00:00 2001 From: Hein Date: Wed, 9 Sep 2026 22:31:00 +0200 Subject: [PATCH] feat(dbml): support case-insensitive table note parsing --- pkg/readers/dbml/reader.go | 65 ++++++++++++++++++++++++++++++--- pkg/readers/dbml/reader_test.go | 11 +++++- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/pkg/readers/dbml/reader.go b/pkg/readers/dbml/reader.go index 298a1ec..5326413 100644 --- a/pkg/readers/dbml/reader.go +++ b/pkg/readers/dbml/reader.go @@ -454,7 +454,7 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) { // for a column declaration. if inTableNote { if line == "'''" { - currentTable.Description = strings.TrimSpace(strings.Join(tableNoteLines, "\n")) + setTableNote(currentTable, strings.TrimSpace(strings.Join(tableNoteLines, "\n"))) inTableNote = false tableNoteLines = nil continue @@ -557,9 +557,10 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) { continue } - // Parse table note - if inTable && currentTable != nil && strings.HasPrefix(line, "Note:") { - note := strings.TrimPrefix(line, "Note:") + // Parse table note. DBML files in the wild use both `Note:` and + // `note:`, so accept either spelling. + if inTable && currentTable != nil && strings.HasPrefix(strings.ToLower(line), "note:") { + note := strings.TrimSpace(line[len("note:"):]) if strings.TrimSpace(note) == "'''" { inTableNote = true tableNoteLines = nil @@ -567,7 +568,7 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) { continue } note = strings.Trim(note, " '\"") - currentTable.Description = note + setTableNote(currentTable, note) continue } @@ -654,6 +655,20 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) { return db, nil } +// setTableNote preserves multiple table notes. The first maps to Description +// and the second to Comment, matching the model fields used by code writers. +func setTableNote(table *models.Table, note string) { + if table.Description == "" { + table.Description = note + return + } + if table.Comment == "" { + table.Comment = note + return + } + table.Comment += "\n" + note +} + // parseColumn parses a DBML column definition func (r *Reader) parseColumn(line, tableName, schemaName string) (*models.Column, *models.Constraint) { // Format: column_name type [attributes] // comment @@ -671,7 +686,7 @@ func (r *Reader) parseColumn(line, tableName, schemaName string) (*models.Column // Parse attributes in brackets if attrs != "" { - attrList := strings.Split(attrs, ",") + attrList := splitColumnAttrs(attrs) for _, attr := range attrList { attr = strings.TrimSpace(attr) @@ -754,6 +769,44 @@ func (r *Reader) parseColumn(line, tableName, schemaName string) (*models.Column return column, constraint } +// splitColumnAttrs splits a DBML attribute list on top-level commas. Notes and +// quoted defaults may contain commas of their own, which are part of the value +// rather than attribute separators. +func splitColumnAttrs(attrs string) []string { + var result []string + start := 0 + var quote byte + escaped := false + + for i := 0; i < len(attrs); i++ { + ch := attrs[i] + if quote != 0 { + if escaped { + escaped = false + continue + } + if ch == '\\' { + escaped = true + continue + } + if ch == quote { + quote = 0 + } + continue + } + + switch ch { + case '\'', '"', '`': + quote = ch + case ',': + result = append(result, attrs[start:i]) + start = i + 1 + } + } + + return append(result, attrs[start:]) +} + func splitInlineComment(line string) (content, inlineComment string) { commentStart := strings.Index(line, "//") if commentStart == -1 { diff --git a/pkg/readers/dbml/reader_test.go b/pkg/readers/dbml/reader_test.go index 8a26181..fa45e05 100644 --- a/pkg/readers/dbml/reader_test.go +++ b/pkg/readers/dbml/reader_test.go @@ -1037,12 +1037,13 @@ func TestReader_ColumnPKOrderPreserved(t *testing.T) { func TestReader_MultilineTableNote(t *testing.T) { dbmlContent := "Table \"info\".\"city\" {\n" + " \"id_city\" serial [pk, not null, increment]\n" + - " \"name\" text [not null]\n\n" + - " Note: '''\n" + + " \"name\" text [not null, note: 'first, second, third']\n\n" + + " note: '''\n" + " Cities and municipalities worldwide.\n\n" + " SPATIAL:\n" + " Proximity queries use a GiST index.\n" + " '''\n" + + " Note: 'Short summary'\n" + "}\n" path := filepath.Join(t.TempDir(), "city.dbml") if err := os.WriteFile(path, []byte(dbmlContent), 0o644); err != nil { @@ -1058,7 +1059,13 @@ func TestReader_MultilineTableNote(t *testing.T) { if got, want := table.Description, "Cities and municipalities worldwide.\n\nSPATIAL:\nProximity queries use a GiST index."; got != want { t.Errorf("table description = %q, want %q", got, want) } + if got, want := table.Comment, "Short summary"; got != want { + t.Errorf("table comment = %q, want %q", got, want) + } if got, want := len(table.Columns), 2; got != want { t.Errorf("column count = %d, want %d; note body must not be parsed as columns", got, want) } + if got, want := table.Columns["name"].Comment, "first, second, third"; got != want { + t.Errorf("column note = %q, want %q", got, want) + } }