Skip to content

Commit 01860eb

Browse files
authored
Merge pull request #839 from k1LoW/out-viewpoint
feat: add viewpoint id and tbls out --viewpoint
2 parents 64077a6 + 26cd3b0 commit 01860eb

10 files changed

Lines changed: 250 additions & 12 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,6 +1097,7 @@ You can also define groups of tables within viewpoints.
10971097
10981098
viewpoints:
10991099
-
1100+
id: comments-on-post
11001101
name: comments on post
11011102
desc: Users can comment on each post multiple times and put a star on each comment.
11021103
tables:
@@ -1123,6 +1124,8 @@ viewpoints:
11231124
11241125
```
11251126

1127+
`id` is optional. When set, it is used as the suffix of the viewpoint output file name (e.g. `viewpoint-comments-on-post.md`) instead of the index, so the file name stays stable regardless of the viewpoint order. `id` must be unique and must not contain path separators (`/` or `\`). It is also used to specify the viewpoint in `tbls out --viewpoint`.
1128+
11261129
## Output formats
11271130

11281131
`tbls out` output in various formats.
@@ -1157,6 +1160,20 @@ $ tbls out -t mermaid -o schema.mmd
11571160
$ tbls out -t svg --table users --distance 2 -o users.svg
11581161
```
11591162

1163+
**Viewpoint:**
1164+
1165+
Output only the schema of a specific viewpoint by specifying its `id` (or index).
1166+
1167+
```console
1168+
$ tbls out -t svg --viewpoint comments-on-post -o comments-on-post.svg
1169+
```
1170+
1171+
With `-t md`, the viewpoint document (including groups and the ER diagram inlined as Mermaid) is generated instead of a plain schema document.
1172+
1173+
```console
1174+
$ tbls out -t md --viewpoint comments-on-post -o comments-on-post.md
1175+
```
1176+
11601177
**JSON:**
11611178

11621179
```console

cmd/out.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,15 @@ import (
3838
"github.com/k1LoW/tbls/output/plantuml"
3939
"github.com/k1LoW/tbls/output/xlsx"
4040
"github.com/k1LoW/tbls/output/yaml"
41+
"github.com/k1LoW/tbls/schema"
4142
"github.com/spf13/cobra"
4243
)
4344

4445
var (
45-
format string
46-
outPath string
47-
distance int
46+
format string
47+
outPath string
48+
distance int
49+
viewpoint string
4850
)
4951

5052
// outCmd represents the doc command.
@@ -79,6 +81,18 @@ var outCmd = &cobra.Command{
7981
return err
8082
}
8183

84+
var vp *schema.Viewpoint
85+
var vpIndex int
86+
if viewpoint != "" {
87+
v, i, err := s.FindViewpoint(viewpoint)
88+
if err != nil {
89+
return err
90+
}
91+
vp = v
92+
vpIndex = i
93+
s = v.Schema
94+
}
95+
8296
var o output.Output
8397

8498
switch format {
@@ -124,6 +138,18 @@ var outCmd = &cobra.Command{
124138
wr = os.Stdout
125139
}
126140

141+
// For Markdown, output the dedicated viewpoint document (ER + groups) instead of
142+
// the plain filtered schema. The ER diagram is inlined as Mermaid so the single
143+
// output file stays self-contained without a separate image file.
144+
if vp != nil && format == "md" {
145+
c.ER.Skip = false
146+
c.ER.Format = "mermaid"
147+
if err := md.New(c).OutputViewpoint(wr, vpIndex, vp); err != nil {
148+
return err
149+
}
150+
return nil
151+
}
152+
127153
if err := o.OutputSchema(wr, s); err != nil {
128154
return err
129155
}
@@ -167,5 +193,6 @@ func init() {
167193
outCmd.Flags().StringSliceVarP(&excludes, "exclude", "", []string{}, "tables to exclude")
168194
outCmd.Flags().StringSliceVarP(&labels, "label", "", []string{}, "table labels to be included")
169195
outCmd.Flags().IntVarP(&distance, "distance", "", 0, "distance between related tables to be displayed")
196+
outCmd.Flags().StringVarP(&viewpoint, "viewpoint", "", "", "output only the specified viewpoint (by id, or by index); with -t md the viewpoint document (groups + ER) is generated")
170197
outCmd.Flags().StringVarP(&when, "when", "", "", "command execute condition")
171198
}

config/config.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,13 +322,34 @@ func (c *Config) validate() error {
322322
if !lo.Contains(SupportERFormat, c.ER.Format) {
323323
return fmt.Errorf("unsupported ER format: %s", c.ER.Format)
324324
}
325+
seenViewpointIDs := map[string]int{}
326+
seenViewpointNames := map[string]int{}
325327
for i, v := range c.Viewpoints {
326328
if v.Name == "" {
327329
return fmt.Errorf("viewpoints[%d] name is required", i)
328330
}
329331
if v.Desc == "" {
330332
return fmt.Errorf("viewpoints[%d] description is required", i)
331333
}
334+
if v.ID != "" {
335+
// id is embedded into output file paths (e.g. viewpoint-<id>.md), so path
336+
// separators would allow writing outside the intended output directory.
337+
if strings.ContainsAny(v.ID, `/\`) {
338+
return fmt.Errorf("viewpoints[%d] id '%s' must not contain path separators ('/' or '\\')", i, v.ID)
339+
}
340+
if j, ok := seenViewpointIDs[v.ID]; ok {
341+
return fmt.Errorf("viewpoints[%d] id '%s' is duplicated with viewpoints[%d]", i, v.ID, j)
342+
}
343+
seenViewpointIDs[v.ID] = i
344+
}
345+
// An id-based name and an index-based name can collide (e.g. viewpoints[0] without id
346+
// produces viewpoint-0, and viewpoints[1] with id "0" produces viewpoint-0 too), which
347+
// would silently overwrite output files. Reject such conflicts on the derived name.
348+
name := schema.ViewpointName(v.ID, i)
349+
if j, ok := seenViewpointNames[name]; ok {
350+
return fmt.Errorf("viewpoints[%d] output name '%s' conflicts with viewpoints[%d]", i, name, j)
351+
}
352+
seenViewpointNames[name] = i
332353
for j, g := range v.Groups {
333354
if g.Name == "" {
334355
return fmt.Errorf("viewpoints[%d].groups[%d] name is required", i, j)
@@ -492,6 +513,7 @@ func (c *Config) ModifySchema(s *schema.Schema) error {
492513
tables = left
493514
}
494515
s.Viewpoints = s.Viewpoints.Merge(&schema.Viewpoint{
516+
ID: v.ID,
495517
Name: v.Name,
496518
Desc: v.Desc,
497519
Labels: v.Labels,
@@ -528,6 +550,7 @@ func (c *Config) ModifySchema(s *schema.Schema) error {
528550
for _, tt := range ts {
529551
tt.Viewpoints = append(tt.Viewpoints, &schema.TableViewpoint{
530552
Index: vi,
553+
ID: v.ID,
531554
Name: v.Name,
532555
Desc: v.Desc,
533556
})

config/config_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,84 @@ func TestValidate(t *testing.T) {
596596
}
597597
}
598598

599+
func TestValidateViewpointID(t *testing.T) {
600+
tests := []struct {
601+
name string
602+
viewpoints []Viewpoint
603+
wantErr bool
604+
}{
605+
{"no id", []Viewpoint{{Name: "A", Desc: "a"}, {Name: "B", Desc: "b"}}, false},
606+
{"unique ids", []Viewpoint{{ID: "a", Name: "A", Desc: "a"}, {ID: "b", Name: "B", Desc: "b"}}, false},
607+
{"empty ids are not duplicated", []Viewpoint{{Name: "A", Desc: "a"}, {Name: "B", Desc: "b"}, {Name: "C", Desc: "c"}}, false},
608+
{"duplicated ids", []Viewpoint{{ID: "a", Name: "A", Desc: "a"}, {ID: "a", Name: "B", Desc: "b"}}, true},
609+
{"id with slash", []Viewpoint{{ID: "../../pwned", Name: "A", Desc: "a"}}, true},
610+
{"id with backslash", []Viewpoint{{ID: `a\b`, Name: "A", Desc: "a"}}, true},
611+
{"id with nested slash", []Viewpoint{{ID: "a/b", Name: "A", Desc: "a"}}, true},
612+
{"id collides with index-based name", []Viewpoint{{Name: "A", Desc: "a"}, {ID: "0", Name: "B", Desc: "b"}}, true},
613+
{"id matching own index is fine", []Viewpoint{{ID: "0", Name: "A", Desc: "a"}, {Name: "B", Desc: "b"}}, false},
614+
}
615+
for _, tt := range tests {
616+
t.Run(tt.name, func(t *testing.T) {
617+
c, err := New()
618+
if err != nil {
619+
t.Fatal(err)
620+
}
621+
c.Viewpoints = tt.viewpoints
622+
if err := c.validate(); err != nil {
623+
if !tt.wantErr {
624+
t.Errorf("got error: %s", err)
625+
}
626+
return
627+
}
628+
if tt.wantErr {
629+
t.Error("want error")
630+
}
631+
})
632+
}
633+
}
634+
635+
func TestModifySchemaViewpointID(t *testing.T) {
636+
c, err := New()
637+
if err != nil {
638+
t.Fatal(err)
639+
}
640+
c.Viewpoints = []Viewpoint{
641+
{ID: "users-overview", Name: "Users", Desc: "users overview", Tables: []string{"users"}},
642+
{Name: "Posts", Desc: "posts", Tables: []string{"posts"}},
643+
}
644+
s := newTestSchemaViaJSON(t)
645+
if err := c.ModifySchema(s); err != nil {
646+
t.Fatal(err)
647+
}
648+
649+
// ID is propagated to schema viewpoint.
650+
if got := s.Viewpoints[0].ID; got != "users-overview" {
651+
t.Errorf("viewpoint[0].ID = %q, want %q", got, "users-overview")
652+
}
653+
if got := s.Viewpoints[1].ID; got != "" {
654+
t.Errorf("viewpoint[1].ID = %q, want empty", got)
655+
}
656+
657+
// ID is propagated to table viewpoint.
658+
tb, err := s.FindTableByName("users")
659+
if err != nil {
660+
t.Fatal(err)
661+
}
662+
var found bool
663+
for _, tv := range tb.Viewpoints {
664+
if tv.Name != "Users" {
665+
continue
666+
}
667+
found = true
668+
if tv.ID != "users-overview" {
669+
t.Errorf("table viewpoint ID = %q, want %q", tv.ID, "users-overview")
670+
}
671+
}
672+
if !found {
673+
t.Error("users table viewpoint 'Users' not found")
674+
}
675+
}
676+
599677
func TestCheckVersion(t *testing.T) {
600678
tests := []struct {
601679
v string

config/viewpoints.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
type Viewpoint struct {
4+
ID string `yaml:"id,omitempty"`
45
Name string `yaml:"name,omitempty"`
56
Desc string `yaml:"desc,omitempty"`
67
Labels []string `yaml:"labels,omitempty"`

output/gviz/gviz.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ func Output(s *schema.Schema, c *config.Config, force bool) (e error) {
142142

143143
// viewpoints
144144
for i, v := range s.Viewpoints {
145-
fn := fmt.Sprintf("viewpoint-%d.%s", i, erFormat)
145+
fn := fmt.Sprintf("%s.%s", schema.ViewpointName(v.ID, i), erFormat)
146146
fmt.Printf("%s\n", filepath.Join(outputPath, fn))
147147
f, err := os.OpenFile(filepath.Join(fullPath, fn), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) // #nosec
148148
if err != nil {
@@ -227,8 +227,8 @@ func outputErExists(s *schema.Schema, erFormat, path string) bool {
227227
}
228228
}
229229
// viewpoints
230-
for i := range s.Viewpoints {
231-
fn := fmt.Sprintf("viewpoint-%d.%s", i, erFormat)
230+
for i, v := range s.Viewpoints {
231+
fn := fmt.Sprintf("%s.%s", schema.ViewpointName(v.ID, i), erFormat)
232232
if _, err := os.Lstat(filepath.Join(path, fn)); err == nil {
233233
return true
234234
}

output/md/md.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ func (m *Md) OutputViewpoint(wr io.Writer, i int, v *schema.Viewpoint) error {
122122
}
123123
templateData["erDiagram"] = fmt.Sprintf("```mermaid\n%s```", buf.String())
124124
default:
125-
templateData["erDiagram"] = fmt.Sprintf("![er](%sviewpoint-%d.%s)", m.config.BaseURL, i, m.config.ER.Format)
125+
templateData["erDiagram"] = fmt.Sprintf("![er](%s%s.%s)", m.config.BaseURL, mdurl.Encode(schema.ViewpointName(v.ID, i)), m.config.ER.Format)
126126
}
127127
if err := tmpl.Execute(wr, templateData); err != nil {
128128
return errors.WithStack(err)
@@ -184,7 +184,7 @@ func Output(s *schema.Schema, c *config.Config, force bool) (e error) {
184184

185185
// viewpoints
186186
for i, v := range s.Viewpoints {
187-
fn := fmt.Sprintf("viewpoint-%d.md", i)
187+
fn := fmt.Sprintf("%s.md", schema.ViewpointName(v.ID, i))
188188
f, err := os.Create(filepath.Clean(filepath.Join(fullPath, fn)))
189189
if err != nil {
190190
_ = f.Close()
@@ -393,8 +393,8 @@ func DiffSchemaAndDocs(docPath string, s *schema.Schema, c *config.Config) (stri
393393
// viewpoints
394394
for i, v := range s.Viewpoints {
395395
buf := new(bytes.Buffer)
396-
n := fmt.Sprintf("viewpoint-%d", i)
397-
fn := fmt.Sprintf("viewpoint-%d.md", i)
396+
n := schema.ViewpointName(v.ID, i)
397+
fn := fmt.Sprintf("%s.md", n)
398398
to := fmt.Sprintf("%s %s", mdsn, n)
399399
if err := md.OutputViewpoint(buf, i, v); err != nil {
400400
return "", errors.WithStack(err)
@@ -609,7 +609,7 @@ func (m *Md) makeTableTemplateData(t *schema.Table) map[string]interface{} {
609609
desc = output.ShowOnlyFirstParagraph(desc)
610610
}
611611
data := []string{
612-
fmt.Sprintf("[%s](viewpoint-%d.md)", v.Name, v.Index),
612+
fmt.Sprintf("[%s](%s.md)", v.Name, mdurl.Encode(schema.ViewpointName(v.ID, v.Index))),
613613
desc,
614614
}
615615

@@ -925,7 +925,7 @@ func (m *Md) viewpointsData(viewpoints []*schema.Viewpoint, number, adjust, show
925925
desc = output.ShowOnlyFirstParagraph(desc)
926926
}
927927
d := []string{
928-
fmt.Sprintf("[%s](%sviewpoint-%d.md)", v.Name, m.config.BaseURL, i),
928+
fmt.Sprintf("[%s](%s%s.md)", v.Name, m.config.BaseURL, mdurl.Encode(schema.ViewpointName(v.ID, i))),
929929
desc,
930930
}
931931
data = append(data, d)

schema/schema.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"sort"
8+
"strconv"
89
"strings"
910

1011
wildcard "github.com/IGLOU-EU/go-wildcard/v2"
@@ -52,6 +53,7 @@ func (labels Labels) Contains(name string) bool {
5253

5354
// Viewpoint is the struct for viewpoint information.
5455
type Viewpoint struct {
56+
ID string `json:"id,omitempty"`
5557
Name string `json:"name"`
5658
Desc string `json:"desc"`
5759
Labels []string `json:"labels,omitempty"`
@@ -82,6 +84,31 @@ func (vs Viewpoints) Merge(in *Viewpoint) Viewpoints {
8284
return append(vs, in)
8385
}
8486

87+
// ViewpointName returns the basename (without extension) used for the viewpoint output file.
88+
// When id is set it is used to keep the name stable regardless of viewpoint order, otherwise the index is used.
89+
// Config validation rejects ids with path separators, but schemas loaded directly (e.g. json://) bypass it,
90+
// so fall back to the index-based name to keep the result safe to embed in file paths.
91+
func ViewpointName(id string, index int) string {
92+
if id != "" && !strings.ContainsAny(id, `/\`) {
93+
return fmt.Sprintf("viewpoint-%s", id)
94+
}
95+
return fmt.Sprintf("viewpoint-%d", index)
96+
}
97+
98+
// FindViewpoint finds a viewpoint by id, falling back to index when locator is a number.
99+
// It also returns the index of the viewpoint.
100+
func (s *Schema) FindViewpoint(locator string) (*Viewpoint, int, error) {
101+
for i, v := range s.Viewpoints {
102+
if v.ID != "" && v.ID == locator {
103+
return v, i, nil
104+
}
105+
}
106+
if index, err := strconv.Atoi(locator); err == nil && index >= 0 && index < len(s.Viewpoints) {
107+
return s.Viewpoints[index], index, nil
108+
}
109+
return nil, 0, fmt.Errorf("viewpoint not found: %s", locator)
110+
}
111+
85112
// Index is the struct for database index.
86113
type Index struct {
87114
Name string `json:"name"`
@@ -130,6 +157,7 @@ type Column struct {
130157

131158
type TableViewpoint struct {
132159
Index int `json:"index"`
160+
ID string `json:"id,omitempty"`
133161
Name string `json:"name"`
134162
Desc string `json:"desc"`
135163
}

0 commit comments

Comments
 (0)