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 @@ -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