diff --git a/README.md b/README.md
index d07c996..5c3c759 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,8 @@ dberd --source cockroach \
--format-to-file schema.d2 \
--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"
```
@@ -129,6 +131,38 @@ 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:
+
+```yaml
+references:
+ - source:
+ table: public.orders
+ column: billing_contact
+ 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. `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
docker run --rm -v $(pwd):/work ghcr.io/holydocs/dberd:latest \
diff --git a/cmd/dberd/main.go b/cmd/dberd/main.go
index da3dcd4..8e22b42 100644
--- a/cmd/dberd/main.go
+++ b/cmd/dberd/main.go
@@ -27,6 +27,8 @@ 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")
+ logicalReferencesFile := flag.String("logical-references-file", "", "YAML file containing predefined logical references")
help := flag.Bool("help", false, "Show help")
@@ -75,6 +77,21 @@ 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()
+ }
schema.Sort()
fs, err := target.FormatSchema(ctx, schema)
@@ -106,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 c012fe3..1f87094 100644
--- a/dberd.go
+++ b/dberd.go
@@ -50,6 +50,109 @@ 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],
+ Name: "logical " + column.Name,
+ })
+ 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, "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")
+ }
+
+ 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
@@ -119,14 +222,15 @@ 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"`
+ 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 d313b23..781af01 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) {
@@ -352,3 +354,374 @@ 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"},
+ Name: "logical " + sourceColumn,
+ }
+ }
+
+ 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: "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{
+ 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)
+ })
+ }
+}
+
+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
+ 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"},
+ 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},
+ },
+ {
+ 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 {
+ 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..1bfd989
--- /dev/null
+++ b/examples/logical-references.yaml
@@ -0,0 +1,8 @@
+references:
+ - source:
+ table: public.orders
+ column: billing_contact
+ target:
+ table: public.contacts
+ column: external_id
+ name: billing contact
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
+}
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 @@
-PUBLIC USERS id INT8 NOT NULL PK name VARCHAR(255) NOT NULL email VARCHAR(255) NOT NULL created_at TIMESTAMP DEFAULT current_timestamp() ROLES id INT8 NOT NULL PK name VARCHAR(50) NOT NULL description STRING created_at TIMESTAMP DEFAULT current_timestamp() USER_ROLES user_id INT8 NOT NULL PK role_id INT8 NOT NULL PK assigned_at TIMESTAMP DEFAULT current_timestamp() POSTS id INT8 NOT NULL PK user_id INT8 NOT NULL title VARCHAR(255) NOT NULL content STRING created_at TIMESTAMP DEFAULT current_timestamp() CATEGORIES id INT8 NOT NULL PK name VARCHAR(100) NOT NULL description STRING parent_id INT8 created_at TIMESTAMP DEFAULT current_timestamp() POST_CATEGORIES post_id INT8 NOT NULL PK category_id INT8 NOT NULL PK COMMENTS id INT8 NOT NULL PK post_id INT8 NOT NULL user_id INT8 NOT NULL content STRING NOT NULL created_at TIMESTAMP DEFAULT current_timestamp()
-
-
+ PUBLIC USERS id INT8 NOT NULL PK name VARCHAR(255) NOT NULL email VARCHAR(255) NOT NULL created_at TIMESTAMP DEFAULT current_timestamp() ROLES id INT8 NOT NULL PK name VARCHAR(50) NOT NULL description STRING created_at TIMESTAMP DEFAULT current_timestamp() USER_ROLES user_id INT8 NOT NULL PK role_id INT8 NOT NULL PK assigned_at TIMESTAMP DEFAULT current_timestamp() POSTS id INT8 NOT NULL PK user_id INT8 NOT NULL title VARCHAR(255) NOT NULL content STRING created_at TIMESTAMP DEFAULT current_timestamp() CATEGORIES id INT8 NOT NULL PK name VARCHAR(100) NOT NULL description STRING parent_id INT8 created_at TIMESTAMP DEFAULT current_timestamp() POST_CATEGORIES post_id INT8 NOT NULL PK category_id INT8 NOT NULL PK COMMENTS id INT8 NOT NULL PK post_id INT8 NOT NULL user_id INT8 NOT NULL content STRING NOT NULL created_at TIMESTAMP 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