From e62d2e3df2a04d038e302f6efefb2336a19a3448 Mon Sep 17 00:00:00 2001 From: Alexey Shumkin Date: Tue, 4 Aug 2026 16:20:17 +0300 Subject: [PATCH 1/5] feat: add optional logical schema references Infer references from _id columns when --add-logical-references is enabled. Match qualified tables within their schema, support singular and plural table names, preserve explicit references, and skip ambiguous targets. Cover logical reference inference, skipped tables, ordering, and idempotency. --- README.md | 1 + cmd/dberd/main.go | 4 ++ dberd.go | 91 +++++++++++++++++++++++++ dberd_test.go | 164 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 260 insertions(+) diff --git a/README.md b/README.md index d07c996..b9000c8 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ dberd --source cockroach \ --format-to-file schema.d2 \ --render-to-file schema.svg \ --skip-tables goose_db_version,schema_migrations \ + --add-logical-references \ --source-dsn "postgres://user@host:port/db?sslmode=disable" ``` diff --git a/cmd/dberd/main.go b/cmd/dberd/main.go index da3dcd4..6bea508 100644 --- a/cmd/dberd/main.go +++ b/cmd/dberd/main.go @@ -27,6 +27,7 @@ func main() { renderToFile := flag.String("render-to-file", "", "Output file for the rendered diagram") sourceDSN := flag.String("source-dsn", "", "Connection string for source database") skipTables := flag.String("skip-tables", "", "Comma-separated table names to omit from the output schema") + addLogicalReferences := flag.Bool("add-logical-references", false, "Infer references from
_id column names") help := flag.Bool("help", false, "Show help") @@ -75,6 +76,9 @@ func main() { } schema.SkipTables(strings.Split(*skipTables, ",")...) + if *addLogicalReferences { + schema.AddLogicalReferences() + } schema.Sort() fs, err := target.FormatSchema(ctx, schema) diff --git a/dberd.go b/dberd.go index c012fe3..5a0ccb7 100644 --- a/dberd.go +++ b/dberd.go @@ -50,6 +50,97 @@ func (s *Schema) Sort() { }) } +// AddLogicalReferences adds references inferred from
_id column names. +// Existing references take precedence, and targets must be in the same schema. +func (s *Schema) AddLogicalReferences() { + type tableAlias struct { + qualifier string + name string + } + + referencedSources := make(map[TableColumn]struct{}, len(s.References)) + for _, reference := range s.References { + referencedSources[reference.Source] = struct{}{} + } + + targetsByAlias := make(map[tableAlias][]TableColumn) + for _, table := range s.Tables { + if !tableHasColumn(table, "id") { + continue + } + + qualifier, name := splitTableName(table.Name) + target := TableColumn{Table: table.Name, Column: "id"} + for _, alias := range logicalTableAliases(name) { + key := tableAlias{qualifier: qualifier, name: alias} + targetsByAlias[key] = append(targetsByAlias[key], target) + } + } + + for _, table := range s.Tables { + qualifier, _ := splitTableName(table.Name) + for _, column := range table.Columns { + stem, ok := strings.CutSuffix(column.Name, "_id") + if !ok || stem == "" { + continue + } + + source := TableColumn{Table: table.Name, Column: column.Name} + if _, ok := referencedSources[source]; ok { + continue + } + + targets := targetsByAlias[tableAlias{qualifier: qualifier, name: stem}] + if len(targets) != 1 { + continue + } + + s.References = append(s.References, Reference{Source: source, Target: targets[0]}) + referencedSources[source] = struct{}{} + } + } +} + +func splitTableName(name string) (string, string) { + separator := strings.LastIndexByte(name, '.') + if separator == -1 { + return "", name + } + + return name[:separator], name[separator+1:] +} + +func logicalTableAliases(name string) []string { + if name == "" { + return nil + } + + aliases := []string{name} + var singular string + switch { + case strings.HasSuffix(name, "ies"): + singular = strings.TrimSuffix(name, "ies") + "y" + case strings.HasSuffix(name, "s"): + singular = strings.TrimSuffix(name, "s") + } + + if singular != "" && singular != name { + aliases = append(aliases, singular) + } + + return aliases +} + +func tableHasColumn(table Table, name string) bool { + for _, column := range table.Columns { + if column.Name == name { + return true + } + } + + return false +} + // SkipTables removes tables with the given names and all references to them. // An unqualified name matches tables in every schema, while a qualified name // matches only the exact table name. Names are case-sensitive; surrounding diff --git a/dberd_test.go b/dberd_test.go index d313b23..ce4bcef 100644 --- a/dberd_test.go +++ b/dberd_test.go @@ -352,3 +352,167 @@ func TestSchema_SkipTables(t *testing.T) { }) } } + +func TestSchema_AddLogicalReferences(t *testing.T) { + t.Parallel() + + table := func(name string, columns ...string) Table { + result := Table{ + Name: name, + Columns: make([]Column, 0, len(columns)), + } + for _, column := range columns { + result.Columns = append(result.Columns, Column{Name: column}) + } + return result + } + reference := func(sourceTable, sourceColumn, targetTable string) Reference { + return Reference{ + Source: TableColumn{Table: sourceTable, Column: sourceColumn}, + Target: TableColumn{Table: targetTable, Column: "id"}, + } + } + + tests := []struct { + name string + schema Schema + skipTables []string + repeat bool + sort bool + expected []Reference + }{ + { + name: "infers exact and regular plural names", + schema: Schema{Tables: []Table{ + table("account", "id"), + table("users", "id"), + table("events", "account_id", "user_id"), + }}, + expected: []Reference{ + reference("events", "account_id", "account"), + reference("events", "user_id", "users"), + }, + }, + { + name: "infers ies plural from singular and exact plural stems", + schema: Schema{Tables: []Table{ + table("categories", "id"), + table("posts", "category_id", "categories_id"), + }}, + expected: []Reference{ + reference("posts", "category_id", "categories"), + reference("posts", "categories_id", "categories"), + }, + }, + { + name: "matches and preserves the same qualifier", + schema: Schema{Tables: []Table{ + table("public.posts", "user_id"), + table("public.users", "id"), + table("audit.users", "id"), + }}, + expected: []Reference{ + reference("public.posts", "user_id", "public.users"), + }, + }, + { + name: "does not fall back across qualifiers", + schema: Schema{Tables: []Table{ + table("public.posts", "account_id"), + table("audit.accounts", "id"), + }}, + }, + { + name: "does not infer an ambiguous alias", + schema: Schema{Tables: []Table{ + table("public.posts", "category_id"), + table("public.category", "id"), + table("public.categories", "id"), + }}, + }, + { + name: "requires an exact id column but not a primary key", + schema: Schema{Tables: []Table{ + table("events", "account_id", "user_id"), + table("account", "id"), + table("users", "ID"), + }}, + expected: []Reference{ + reference("events", "account_id", "account"), + }, + }, + { + name: "matches source column names case sensitively", + schema: Schema{Tables: []Table{ + table("events", "User_id", "_id", "userID", "parent_id"), + table("users", "id"), + }}, + }, + { + name: "keeps explicit references authoritative", + schema: Schema{ + Tables: []Table{ + table("posts", "category_id", "user_id"), + table("categories", "id"), + table("users", "id"), + }, + References: []Reference{ + reference("posts", "category_id", "taxonomy.tags"), + }, + }, + expected: []Reference{ + reference("posts", "category_id", "taxonomy.tags"), + reference("posts", "user_id", "users"), + }, + }, + { + name: "is idempotent and permits a unique self reference", + schema: Schema{Tables: []Table{ + table("categories", "id", "category_id"), + }}, + repeat: true, + expected: []Reference{ + reference("categories", "category_id", "categories"), + }, + }, + { + name: "does not target a skipped table", + schema: Schema{Tables: []Table{ + table("posts", "category_id"), + table("categories", "id"), + }}, + skipTables: []string{"categories"}, + }, + { + name: "sorts inferred references with existing schema ordering", + schema: Schema{Tables: []Table{ + table("posts", "user_id", "category_id"), + table("users", "id"), + table("categories", "id"), + }}, + sort: true, + expected: []Reference{ + reference("posts", "category_id", "categories"), + reference("posts", "user_id", "users"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + actual := tt.schema + actual.SkipTables(tt.skipTables...) + actual.AddLogicalReferences() + if tt.repeat { + actual.AddLogicalReferences() + } + if tt.sort { + actual.Sort() + } + + assert.Equal(t, tt.expected, actual.References) + }) + } +} From ae548cbbfaf2d6e040b67bb70122e9c8e7358468 Mon Sep 17 00:00:00 2001 From: Alexey Shumkin Date: Tue, 4 Aug 2026 16:38:56 +0300 Subject: [PATCH 2/5] feat: add predefined logical references Load explicit YAML mappings through --logical-references-file and apply them before optional column-name inference. Validate all configured endpoints, reject conflicting targets without partial changes, and ignore exact duplicates. Expose parsing and schema-application APIs, document the file format with an example, and cover YAML decoding and reference validation behavior. --- README.md | 22 +++++ cmd/dberd/main.go | 28 ++++++ dberd.go | 8 +- dberd_test.go | 153 +++++++++++++++++++++++++++++++ examples/logical-references.yaml | 7 ++ go.mod | 2 +- logical_references.go | 127 +++++++++++++++++++++++++ 7 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 examples/logical-references.yaml create mode 100644 logical_references.go diff --git a/README.md b/README.md index b9000c8..e0756ff 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ dberd --source cockroach \ --render-to-file schema.svg \ --skip-tables goose_db_version,schema_migrations \ --add-logical-references \ + --logical-references-file examples/logical-references.yaml \ --source-dsn "postgres://user@host:port/db?sslmode=disable" ``` @@ -130,6 +131,27 @@ Use `--skip-tables` to omit a comma-separated list of tables from the output. Unqualified names, such as `goose_db_version`, match that table in every schema; qualified names, such as `public.goose_db_version`, match only that exact table. +Use `--logical-references-file` to add explicit logical references that are not +defined as database foreign keys. The file is YAML and declares fully qualified +source and target table-column pairs: + +```yaml +references: + - source: + table: public.orders + column: billing_contact + target: + table: public.contacts + column: external_id +``` + +Each endpoint must exist in the extracted schema. Conflicting mappings fail, while +an exact duplicate is ignored. Predefined references are applied after +`--skip-tables` and before optional `--add-logical-references` inference, so an +explicit mapping takes precedence over inferred references for the same source +column. See [`examples/logical-references.yaml`](examples/logical-references.yaml) +for a copyable template. + Or using Docker: ```bash docker run --rm -v $(pwd):/work ghcr.io/holydocs/dberd:latest \ diff --git a/cmd/dberd/main.go b/cmd/dberd/main.go index 6bea508..8e22b42 100644 --- a/cmd/dberd/main.go +++ b/cmd/dberd/main.go @@ -28,6 +28,7 @@ func main() { sourceDSN := flag.String("source-dsn", "", "Connection string for source database") skipTables := flag.String("skip-tables", "", "Comma-separated table names to omit from the output schema") addLogicalReferences := flag.Bool("add-logical-references", false, "Infer references from
_id column names") + logicalReferencesFile := flag.String("logical-references-file", "", "YAML file containing predefined logical references") help := flag.Bool("help", false, "Show help") @@ -76,6 +77,18 @@ func main() { } schema.SkipTables(strings.Split(*skipTables, ",")...) + if *logicalReferencesFile != "" { + predefinedReferences, err := loadPredefinedLogicalReferences(*logicalReferencesFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: Loading predefined logical references %v\n", err) + os.Exit(1) + } + + if err := schema.AddPredefinedLogicalReferences(predefinedReferences); err != nil { + fmt.Fprintf(os.Stderr, "Error: Adding predefined logical references %v\n", err) + os.Exit(1) + } + } if *addLogicalReferences { schema.AddLogicalReferences() } @@ -110,6 +123,21 @@ func main() { } } +func loadPredefinedLogicalReferences(path string) ([]dberd.Reference, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("opening %s: %w", path, err) + } + defer file.Close() + + references, err := dberd.ParsePredefinedLogicalReferences(file) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + + return references, nil +} + func pickSource(sourceType, sourceDSN string) (dberd.Source, error) { switch sourceType { case "postgres": diff --git a/dberd.go b/dberd.go index 5a0ccb7..f554eef 100644 --- a/dberd.go +++ b/dberd.go @@ -210,14 +210,14 @@ type Column struct { // TableColumn represents a reference to a specific column in a table. type TableColumn struct { - Table string `json:"table"` - Column string `json:"column"` + Table string `json:"table" yaml:"table"` + Column string `json:"column" yaml:"column"` } // Reference represents a foreign key relationship between two table columns. type Reference struct { - Source TableColumn `json:"source"` - Target TableColumn `json:"target"` + Source TableColumn `json:"source" yaml:"source"` + Target TableColumn `json:"target" yaml:"target"` } // FormattedSchema represents a formatted database schema. diff --git a/dberd_test.go b/dberd_test.go index ce4bcef..6327fa9 100644 --- a/dberd_test.go +++ b/dberd_test.go @@ -1,9 +1,11 @@ package dberd import ( + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSchema_Sort(t *testing.T) { @@ -516,3 +518,154 @@ func TestSchema_AddLogicalReferences(t *testing.T) { }) } } + +func TestParsePredefinedLogicalReferences(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yaml string + expected []Reference + wantErr bool + }{ + { + name: "parses fully qualified non-id references", + yaml: `references: + - source: + table: public.orders + column: billing_contact + target: + table: public.contacts + column: external_id +`, + expected: []Reference{{ + Source: TableColumn{Table: "public.orders", Column: "billing_contact"}, + Target: TableColumn{Table: "public.contacts", Column: "external_id"}, + }}, + }, + { + name: "rejects malformed yaml", + yaml: "references: [", + wantErr: true, + }, + { + name: "rejects unknown fields", + yaml: `references: + - source: {table: orders, column: contact_id, unexpected: value} + target: {table: contacts, column: id} +`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + actual, err := ParsePredefinedLogicalReferences(strings.NewReader(tt.yaml)) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestSchema_AddPredefinedLogicalReferences(t *testing.T) { + t.Parallel() + + table := func(name string, columns ...string) Table { + result := Table{Name: name, Columns: make([]Column, 0, len(columns))} + for _, column := range columns { + result.Columns = append(result.Columns, Column{Name: column}) + } + return result + } + + configuredReference := Reference{ + Source: TableColumn{Table: "public.orders", Column: "billing_contact"}, + Target: TableColumn{Table: "public.contacts", Column: "external_id"}, + } + + tests := []struct { + name string + schema Schema + references []Reference + expected []Reference + wantErr string + }{ + { + name: "adds a valid reference and ignores an exact duplicate", + schema: Schema{Tables: []Table{ + table("public.orders", "billing_contact"), + table("public.contacts", "external_id"), + }}, + references: []Reference{configuredReference, configuredReference}, + expected: []Reference{configuredReference}, + }, + { + name: "does not change schema when any endpoint is missing", + schema: Schema{Tables: []Table{ + table("public.orders", "billing_contact"), + table("public.contacts", "external_id"), + }}, + references: []Reference{ + configuredReference, + {Source: TableColumn{Table: "public.orders", Column: "missing"}, Target: configuredReference.Target}, + }, + wantErr: "source column public.orders.missing does not exist", + }, + { + name: "rejects a different target for an existing source", + schema: Schema{ + Tables: []Table{ + table("public.orders", "billing_contact"), + table("public.contacts", "external_id"), + table("public.legacy_contacts", "id"), + }, + References: []Reference{{ + Source: configuredReference.Source, + Target: TableColumn{Table: "public.legacy_contacts", Column: "id"}, + }}, + }, + references: []Reference{configuredReference}, + expected: []Reference{{ + Source: configuredReference.Source, + Target: TableColumn{Table: "public.legacy_contacts", Column: "id"}, + }}, + wantErr: "source column public.orders.billing_contact already references public.legacy_contacts.id", + }, + { + name: "ignores an exact duplicate when the schema already contains duplicates", + schema: Schema{ + Tables: []Table{ + table("public.orders", "billing_contact"), + table("public.contacts", "external_id"), + }, + References: []Reference{configuredReference, configuredReference}, + }, + references: []Reference{configuredReference}, + expected: []Reference{configuredReference, configuredReference}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + actual := tt.schema + err := actual.AddPredefinedLogicalReferences(tt.references) + + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.expected, actual.References) + }) + } +} diff --git a/examples/logical-references.yaml b/examples/logical-references.yaml new file mode 100644 index 0000000..ae33dc9 --- /dev/null +++ b/examples/logical-references.yaml @@ -0,0 +1,7 @@ +references: + - source: + table: public.orders + column: billing_contact + target: + table: public.contacts + column: external_id diff --git a/go.mod b/go.mod index 97c21d6..02e5b67 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 go.mongodb.org/mongo-driver v1.17.4 + gopkg.in/yaml.v3 v3.0.1 oss.terrastruct.com/d2 v0.7.0 oss.terrastruct.com/util-go v0.0.0-20250213174338-243d8661088a ) @@ -109,5 +110,4 @@ require ( gonum.org/v1/plot v0.14.0 // indirect google.golang.org/grpc v1.70.0 // indirect google.golang.org/protobuf v1.36.5 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/logical_references.go b/logical_references.go new file mode 100644 index 0000000..cbac541 --- /dev/null +++ b/logical_references.go @@ -0,0 +1,127 @@ +package dberd + +import ( + "errors" + "fmt" + "io" + + "gopkg.in/yaml.v3" +) + +type predefinedLogicalReferences struct { + References []Reference `yaml:"references"` +} + +// ParsePredefinedLogicalReferences decodes predefined logical references from YAML. +func ParsePredefinedLogicalReferences(r io.Reader) ([]Reference, error) { + decoder := yaml.NewDecoder(r) + decoder.KnownFields(true) + + var configuration predefinedLogicalReferences + if err := decoder.Decode(&configuration); err != nil { + return nil, fmt.Errorf("decoding predefined logical references: %w", err) + } + + var extraDocument any + if err := decoder.Decode(&extraDocument); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New("decoding predefined logical references: multiple YAML documents") + } + return nil, fmt.Errorf("decoding predefined logical references: %w", err) + } + + return configuration.References, nil +} + +// AddPredefinedLogicalReferences adds explicitly configured references. +// Each endpoint must exist in the schema, and existing references take precedence. +func (s *Schema) AddPredefinedLogicalReferences(references []Reference) error { + tableColumns := make(map[string]map[string]struct{}, len(s.Tables)) + for _, table := range s.Tables { + columns := make(map[string]struct{}, len(table.Columns)) + for _, column := range table.Columns { + columns[column.Name] = struct{}{} + } + tableColumns[table.Name] = columns + } + + existingTargets := make(map[TableColumn][]TableColumn, len(s.References)) + for _, reference := range s.References { + existingTargets[reference.Source] = append(existingTargets[reference.Source], reference.Target) + } + + pendingTargets := make(map[TableColumn]TableColumn, len(references)) + toAdd := make([]Reference, 0, len(references)) + var validationErrors []error + + for _, reference := range references { + if err := validateReferenceEndpoint(tableColumns, "source", reference.Source); err != nil { + validationErrors = append(validationErrors, err) + } + if err := validateReferenceEndpoint(tableColumns, "target", reference.Target); err != nil { + validationErrors = append(validationErrors, err) + } + if len(validationErrors) > 0 { + continue + } + + if targets, ok := existingTargets[reference.Source]; ok { + conflictingTarget, hasConflict := conflictingTableColumn(targets, reference.Target) + if !hasConflict { + continue + } + validationErrors = append(validationErrors, fmt.Errorf( + "source column %s.%s already references %s.%s", + reference.Source.Table, + reference.Source.Column, + conflictingTarget.Table, + conflictingTarget.Column, + )) + continue + } + + if target, ok := pendingTargets[reference.Source]; ok { + if target == reference.Target { + continue + } + validationErrors = append(validationErrors, fmt.Errorf( + "source column %s.%s already references %s.%s", + reference.Source.Table, + reference.Source.Column, + target.Table, + target.Column, + )) + continue + } + + pendingTargets[reference.Source] = reference.Target + toAdd = append(toAdd, reference) + } + + if len(validationErrors) > 0 { + return errors.Join(validationErrors...) + } + + s.References = append(s.References, toAdd...) + return nil +} + +func validateReferenceEndpoint(tableColumns map[string]map[string]struct{}, endpointName string, endpoint TableColumn) error { + columns, ok := tableColumns[endpoint.Table] + if !ok { + return fmt.Errorf("%s table %s does not exist", endpointName, endpoint.Table) + } + if _, ok := columns[endpoint.Column]; !ok { + return fmt.Errorf("%s column %s.%s does not exist", endpointName, endpoint.Table, endpoint.Column) + } + return nil +} + +func conflictingTableColumn(columns []TableColumn, column TableColumn) (TableColumn, bool) { + for _, candidate := range columns { + if candidate != column { + return candidate, true + } + } + return TableColumn{}, false +} From 22053c112215a901be0986a241e1ea58d18de4a1 Mon Sep 17 00:00:00 2001 From: Alexey Shumkin Date: Tue, 4 Aug 2026 17:03:13 +0300 Subject: [PATCH 3/5] feat: name logical references Add optional reference names to the schema and YAML mappings. Name inferred references as "logical " and render names as diagram relationship labels while retaining existing labels for unnamed references. Document the naming behavior and update target golden fixtures and tests. --- README.md | 8 +- dberd.go | 7 +- dberd_test.go | 38 ++++++ examples/logical-references.yaml | 1 + target/d2/d2_test.go | 2 +- target/d2/schema.tmpl | 2 +- target/d2/testdata/schema.d2 | 2 +- target/d2/testdata/schema.svg | 176 ++++++++++++++------------- target/json/json_test.go | 2 +- target/json/testdata/schema.json | 3 +- target/mermaid/mermaid_test.go | 2 +- target/mermaid/schema.tmpl | 4 +- target/mermaid/testdata/schema.mmd | 2 +- target/plantuml/plantuml_test.go | 2 +- target/plantuml/schema.tmpl | 2 +- target/plantuml/testdata/schema.puml | 2 +- 16 files changed, 156 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index e0756ff..0966d08 100644 --- a/README.md +++ b/README.md @@ -143,14 +143,18 @@ references: target: table: public.contacts column: external_id + name: billing contact ``` Each endpoint must exist in the extracted schema. Conflicting mappings fail, while an exact duplicate is ignored. Predefined references are applied after `--skip-tables` and before optional `--add-logical-references` inference, so an explicit mapping takes precedence over inferred references for the same source -column. See [`examples/logical-references.yaml`](examples/logical-references.yaml) -for a copyable template. +column. `name` is optional and labels the relationship in generated diagrams; +unnamed predefined references keep the target format's existing endpoint-based +label. Inferred references are named `logical `. See +[`examples/logical-references.yaml`](examples/logical-references.yaml) for a +copyable template. Or using Docker: ```bash diff --git a/dberd.go b/dberd.go index f554eef..8419795 100644 --- a/dberd.go +++ b/dberd.go @@ -95,7 +95,11 @@ func (s *Schema) AddLogicalReferences() { continue } - s.References = append(s.References, Reference{Source: source, Target: targets[0]}) + s.References = append(s.References, Reference{ + Source: source, + Target: targets[0], + Name: "logical " + column.Name, + }) referencedSources[source] = struct{}{} } } @@ -218,6 +222,7 @@ type TableColumn struct { type Reference struct { Source TableColumn `json:"source" yaml:"source"` Target TableColumn `json:"target" yaml:"target"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` } // FormattedSchema represents a formatted database schema. diff --git a/dberd_test.go b/dberd_test.go index 6327fa9..058f3b6 100644 --- a/dberd_test.go +++ b/dberd_test.go @@ -372,6 +372,7 @@ func TestSchema_AddLogicalReferences(t *testing.T) { return Reference{ Source: TableColumn{Table: sourceTable, Column: sourceColumn}, Target: TableColumn{Table: targetTable, Column: "id"}, + Name: "logical " + sourceColumn, } } @@ -537,6 +538,19 @@ func TestParsePredefinedLogicalReferences(t *testing.T) { target: table: public.contacts column: external_id + name: billing contact +`, + expected: []Reference{{ + Source: TableColumn{Table: "public.orders", Column: "billing_contact"}, + Target: TableColumn{Table: "public.contacts", Column: "external_id"}, + Name: "billing contact", + }}, + }, + { + name: "keeps an omitted name empty", + yaml: `references: + - source: {table: public.orders, column: billing_contact} + target: {table: public.contacts, column: external_id} `, expected: []Reference{{ Source: TableColumn{Table: "public.orders", Column: "billing_contact"}, @@ -651,6 +665,30 @@ func TestSchema_AddPredefinedLogicalReferences(t *testing.T) { references: []Reference{configuredReference}, expected: []Reference{configuredReference, configuredReference}, }, + { + name: "ignores an exact duplicate with a different name", + schema: Schema{ + Tables: []Table{ + table("public.orders", "billing_contact"), + table("public.contacts", "external_id"), + }, + References: []Reference{{ + Source: configuredReference.Source, + Target: configuredReference.Target, + Name: "existing name", + }}, + }, + references: []Reference{{ + Source: configuredReference.Source, + Target: configuredReference.Target, + Name: "configured name", + }}, + expected: []Reference{{ + Source: configuredReference.Source, + Target: configuredReference.Target, + Name: "existing name", + }}, + }, } for _, tt := range tests { diff --git a/examples/logical-references.yaml b/examples/logical-references.yaml index ae33dc9..1bfd989 100644 --- a/examples/logical-references.yaml +++ b/examples/logical-references.yaml @@ -5,3 +5,4 @@ references: target: table: public.contacts column: external_id + name: billing contact diff --git a/target/d2/d2_test.go b/target/d2/d2_test.go index 4a03fde..a253e02 100644 --- a/target/d2/d2_test.go +++ b/target/d2/d2_test.go @@ -93,7 +93,7 @@ func TestFormatSchema(t *testing.T) { {Source: dberd.TableColumn{Table: "public.comments", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}}, {Source: dberd.TableColumn{Table: "public.post_categories", Column: "category_id"}, Target: dberd.TableColumn{Table: "public.categories", Column: "id"}}, {Source: dberd.TableColumn{Table: "public.post_categories", Column: "post_id"}, Target: dberd.TableColumn{Table: "public.posts", Column: "id"}}, - {Source: dberd.TableColumn{Table: "public.posts", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}}, + {Source: dberd.TableColumn{Table: "public.posts", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}, Name: "post author"}, {Source: dberd.TableColumn{Table: "public.user_roles", Column: "role_id"}, Target: dberd.TableColumn{Table: "public.roles", Column: "id"}}, {Source: dberd.TableColumn{Table: "public.user_roles", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}}, }, diff --git a/target/d2/schema.tmpl b/target/d2/schema.tmpl index ab30317..df31701 100644 --- a/target/d2/schema.tmpl +++ b/target/d2/schema.tmpl @@ -12,5 +12,5 @@ direction: right # References {{- range .References }} -{{.Source.Table}}.{{.Source.Column}} -> {{.Target.Table}}.{{.Target.Column}} +{{.Source.Table}}.{{.Source.Column}} -> {{.Target.Table}}.{{.Target.Column}}{{if .Name}}: {{.Name}}{{end}} {{- end }} diff --git a/target/d2/testdata/schema.d2 b/target/d2/testdata/schema.d2 index 4397524..df75ca2 100644 --- a/target/d2/testdata/schema.d2 +++ b/target/d2/testdata/schema.d2 @@ -57,6 +57,6 @@ public.comments.post_id -> public.posts.id public.comments.user_id -> public.users.id public.post_categories.category_id -> public.categories.id public.post_categories.post_id -> public.posts.id -public.posts.user_id -> public.users.id +public.posts.user_id -> public.users.id: post author public.user_roles.role_id -> public.roles.id public.user_roles.user_id -> public.users.id diff --git a/target/d2/testdata/schema.svg b/target/d2/testdata/schema.svg index 60523cb..74343fe 100644 --- a/target/d2/testdata/schema.svg +++ b/target/d2/testdata/schema.svg @@ -1,17 +1,24 @@ - +}]]> @@ -129,7 +136,8 @@ -PUBLICUSERSidINT8 NOT NULLPKnameVARCHAR(255) NOT NULLemailVARCHAR(255) NOT NULLcreated_atTIMESTAMP DEFAULT current_timestamp()ROLESidINT8 NOT NULLPKnameVARCHAR(50) NOT NULLdescriptionSTRINGcreated_atTIMESTAMP DEFAULT current_timestamp()USER_ROLESuser_idINT8 NOT NULLPKrole_idINT8 NOT NULLPKassigned_atTIMESTAMP DEFAULT current_timestamp()POSTSidINT8 NOT NULLPKuser_idINT8 NOT NULLtitleVARCHAR(255) NOT NULLcontentSTRINGcreated_atTIMESTAMP DEFAULT current_timestamp()CATEGORIESidINT8 NOT NULLPKnameVARCHAR(100) NOT NULLdescriptionSTRINGparent_idINT8created_atTIMESTAMP DEFAULT current_timestamp()POST_CATEGORIESpost_idINT8 NOT NULLPKcategory_idINT8 NOT NULLPKCOMMENTSidINT8 NOT NULLPKpost_idINT8 NOT NULLuser_idINT8 NOT NULLcontentSTRING NOT NULLcreated_atTIMESTAMP DEFAULT current_timestamp() - - +PUBLICUSERSidINT8 NOT NULLPKnameVARCHAR(255) NOT NULLemailVARCHAR(255) NOT NULLcreated_atTIMESTAMP DEFAULT current_timestamp()ROLESidINT8 NOT NULLPKnameVARCHAR(50) NOT NULLdescriptionSTRINGcreated_atTIMESTAMP DEFAULT current_timestamp()USER_ROLESuser_idINT8 NOT NULLPKrole_idINT8 NOT NULLPKassigned_atTIMESTAMP DEFAULT current_timestamp()POSTSidINT8 NOT NULLPKuser_idINT8 NOT NULLtitleVARCHAR(255) NOT NULLcontentSTRINGcreated_atTIMESTAMP DEFAULT current_timestamp()CATEGORIESidINT8 NOT NULLPKnameVARCHAR(100) NOT NULLdescriptionSTRINGparent_idINT8created_atTIMESTAMP DEFAULT current_timestamp()POST_CATEGORIESpost_idINT8 NOT NULLPKcategory_idINT8 NOT NULLPKCOMMENTSidINT8 NOT NULLPKpost_idINT8 NOT NULLuser_idINT8 NOT NULLcontentSTRING NOT NULLcreated_atTIMESTAMP DEFAULT current_timestamp() POST AUTHOR + + + \ No newline at end of file diff --git a/target/json/json_test.go b/target/json/json_test.go index f119793..3ceb320 100644 --- a/target/json/json_test.go +++ b/target/json/json_test.go @@ -90,7 +90,7 @@ func TestFormatSchema(t *testing.T) { {Source: dberd.TableColumn{Table: "public.comments", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}}, {Source: dberd.TableColumn{Table: "public.post_categories", Column: "category_id"}, Target: dberd.TableColumn{Table: "public.categories", Column: "id"}}, {Source: dberd.TableColumn{Table: "public.post_categories", Column: "post_id"}, Target: dberd.TableColumn{Table: "public.posts", Column: "id"}}, - {Source: dberd.TableColumn{Table: "public.posts", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}}, + {Source: dberd.TableColumn{Table: "public.posts", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}, Name: "post author"}, {Source: dberd.TableColumn{Table: "public.user_roles", Column: "role_id"}, Target: dberd.TableColumn{Table: "public.roles", Column: "id"}}, {Source: dberd.TableColumn{Table: "public.user_roles", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}}, }, diff --git a/target/json/testdata/schema.json b/target/json/testdata/schema.json index f684aef..eb483e9 100644 --- a/target/json/testdata/schema.json +++ b/target/json/testdata/schema.json @@ -238,7 +238,8 @@ "target": { "table": "public.users", "column": "id" - } + }, + "name": "post author" }, { "source": { diff --git a/target/mermaid/mermaid_test.go b/target/mermaid/mermaid_test.go index a12d995..69c39f3 100644 --- a/target/mermaid/mermaid_test.go +++ b/target/mermaid/mermaid_test.go @@ -60,7 +60,7 @@ func TestFormatSchema(t *testing.T) { References: []dberd.Reference{ {Source: dberd.TableColumn{Table: "public.user_roles", Column: "role_id"}, Target: dberd.TableColumn{Table: "public.roles", Column: "id"}}, {Source: dberd.TableColumn{Table: "public.user_roles", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}}, - {Source: dberd.TableColumn{Table: "public.posts", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}}, + {Source: dberd.TableColumn{Table: "public.posts", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}, Name: "post author"}, }, } diff --git a/target/mermaid/schema.tmpl b/target/mermaid/schema.tmpl index 13aa992..5de70ef 100644 --- a/target/mermaid/schema.tmpl +++ b/target/mermaid/schema.tmpl @@ -9,5 +9,5 @@ erDiagram {{- end }} {{- range .References }} - "{{ .Source.Table }}" }o--|| "{{ .Target.Table }}" : "{{ .Source.Column }} -> {{ .Target.Column }}" -{{- end }} + "{{ .Source.Table }}" }o--|| "{{ .Target.Table }}" : "{{if .Name}}{{.Name}}{{else}}{{ .Source.Column }} -> {{ .Target.Column }}{{end}}" +{{- end }} diff --git a/target/mermaid/testdata/schema.mmd b/target/mermaid/testdata/schema.mmd index fcae90c..55a48e2 100644 --- a/target/mermaid/testdata/schema.mmd +++ b/target/mermaid/testdata/schema.mmd @@ -25,4 +25,4 @@ erDiagram } "public.user_roles" }o--|| "public.roles" : "role_id -> id" "public.user_roles" }o--|| "public.users" : "user_id -> id" - "public.posts" }o--|| "public.users" : "user_id -> id" + "public.posts" }o--|| "public.users" : "post author" diff --git a/target/plantuml/plantuml_test.go b/target/plantuml/plantuml_test.go index c35c679..a6649f8 100644 --- a/target/plantuml/plantuml_test.go +++ b/target/plantuml/plantuml_test.go @@ -90,7 +90,7 @@ func TestFormatSchema(t *testing.T) { {Source: dberd.TableColumn{Table: "public.comments", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}}, {Source: dberd.TableColumn{Table: "public.post_categories", Column: "category_id"}, Target: dberd.TableColumn{Table: "public.categories", Column: "id"}}, {Source: dberd.TableColumn{Table: "public.post_categories", Column: "post_id"}, Target: dberd.TableColumn{Table: "public.posts", Column: "id"}}, - {Source: dberd.TableColumn{Table: "public.posts", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}}, + {Source: dberd.TableColumn{Table: "public.posts", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}, Name: "post author"}, {Source: dberd.TableColumn{Table: "public.user_roles", Column: "role_id"}, Target: dberd.TableColumn{Table: "public.roles", Column: "id"}}, {Source: dberd.TableColumn{Table: "public.user_roles", Column: "user_id"}, Target: dberd.TableColumn{Table: "public.users", Column: "id"}}, }, diff --git a/target/plantuml/schema.tmpl b/target/plantuml/schema.tmpl index ee4bf66..0b891d4 100644 --- a/target/plantuml/schema.tmpl +++ b/target/plantuml/schema.tmpl @@ -16,6 +16,6 @@ table({{.Name}}) { {{- end }} {{- range .References }} -{{.Source.Table}} }o--|| {{.Target.Table}} : {{.Source.Column}} references {{.Target.Column}} +{{.Source.Table}} }o--|| {{.Target.Table}} : {{if .Name}}{{.Name}}{{else}}{{.Source.Column}} references {{.Target.Column}}{{end}} {{- end }} @enduml diff --git a/target/plantuml/testdata/schema.puml b/target/plantuml/testdata/schema.puml index 40a8d29..2f4a138 100644 --- a/target/plantuml/testdata/schema.puml +++ b/target/plantuml/testdata/schema.puml @@ -49,7 +49,7 @@ public.comments }o--|| public.posts : post_id references id public.comments }o--|| public.users : user_id references id public.post_categories }o--|| public.categories : category_id references id public.post_categories }o--|| public.posts : post_id references id -public.posts }o--|| public.users : user_id references id +public.posts }o--|| public.users : post author public.user_roles }o--|| public.roles : role_id references id public.user_roles }o--|| public.users : user_id references id @enduml From 9db0580a9c49fdd8d28e376c2e880dcfcb441abf Mon Sep 17 00:00:00 2001 From: Alexey Shumkin Date: Tue, 4 Aug 2026 23:07:56 +0300 Subject: [PATCH 4/5] chore(doc): add `--add-logical-references` section to README --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 0966d08..5c3c759 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,13 @@ Use `--skip-tables` to omit a comma-separated list of tables from the output. Unqualified names, such as `goose_db_version`, match that table in every schema; qualified names, such as `public.goose_db_version`, match only that exact table. +Use `--add-logical-references` to infer logical references from columns named +`
_id`. The column is linked to the `id` column of a uniquely matching +table in the same schema; singular and regular plural table names are matched. +Ambiguous matches are skipped, and existing references take precedence over +inferred ones. Inferred relationships are labelled `logical ` in +generated diagrams. + Use `--logical-references-file` to add explicit logical references that are not defined as database foreign keys. The file is YAML and declares fully qualified source and target table-column pairs: From 1a8ab90de1cf3cff3e1fbf635bb847b00c5ff1e8 Mon Sep 17 00:00:00 2001 From: Alexey Shumkin Date: Tue, 4 Aug 2026 23:28:18 +0300 Subject: [PATCH 5/5] fix: missing logical references for common es plurals Recognize common -es plural suffixes when deriving singular table aliases, including doubled-z plurals. Cover address, box, dish, quiz, and watch logical-reference inference with regression tests. --- dberd.go | 8 ++++++++ dberd_test.go | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/dberd.go b/dberd.go index 8419795..1f87094 100644 --- a/dberd.go +++ b/dberd.go @@ -124,6 +124,14 @@ func logicalTableAliases(name string) []string { switch { case strings.HasSuffix(name, "ies"): singular = strings.TrimSuffix(name, "ies") + "y" + case strings.HasSuffix(name, "zzes"): + singular = strings.TrimSuffix(name, "zes") + case strings.HasSuffix(name, "ches"), + strings.HasSuffix(name, "shes"), + strings.HasSuffix(name, "sses"), + strings.HasSuffix(name, "xes"), + strings.HasSuffix(name, "zes"): + singular = strings.TrimSuffix(name, "es") case strings.HasSuffix(name, "s"): singular = strings.TrimSuffix(name, "s") } diff --git a/dberd_test.go b/dberd_test.go index 058f3b6..781af01 100644 --- a/dberd_test.go +++ b/dberd_test.go @@ -407,6 +407,24 @@ func TestSchema_AddLogicalReferences(t *testing.T) { reference("posts", "categories_id", "categories"), }, }, + { + name: "infers common es plurals from singular stems", + schema: Schema{Tables: []Table{ + table("addresses", "id"), + table("boxes", "id"), + table("dishes", "id"), + table("quizzes", "id"), + table("watches", "id"), + table("deliveries", "address_id", "box_id", "dish_id", "quiz_id", "watch_id"), + }}, + expected: []Reference{ + reference("deliveries", "address_id", "addresses"), + reference("deliveries", "box_id", "boxes"), + reference("deliveries", "dish_id", "dishes"), + reference("deliveries", "quiz_id", "quizzes"), + reference("deliveries", "watch_id", "watches"), + }, + }, { name: "matches and preserves the same qualifier", schema: Schema{Tables: []Table{