diff --git a/pkg/ui/domain_screens.go b/pkg/ui/domain_screens.go index 8b50c1f..2237b9d 100644 --- a/pkg/ui/domain_screens.go +++ b/pkg/ui/domain_screens.go @@ -207,6 +207,10 @@ func (se *SchemaEditor) showDomainEditor(index int, domain *models.Domain) { se.showDomainList() }) + form.AddButton("Tables", func() { + se.showDomainTables(index) + }) + form.AddButton("Delete", func() { se.showDeleteDomainConfirm(index) }) diff --git a/pkg/ui/main_menu.go b/pkg/ui/main_menu.go index 864d2e4..a135faa 100644 --- a/pkg/ui/main_menu.go +++ b/pkg/ui/main_menu.go @@ -39,6 +39,18 @@ func (se *SchemaEditor) createMainMenu() tview.Primitive { AddItem("Manage Domains", "View, create, edit, and delete domains", 'd', func() { se.showDomainList() }). + AddItem("Manage Indexes", "View, create, edit, and delete table indexes", 'x', func() { + se.showObjectList(se.indexKind()) + }). + AddItem("Manage Views", "View, create, edit, and delete views", 'v', func() { + se.showObjectList(se.viewKind()) + }). + AddItem("Manage Sequences", "View, create, edit, and delete sequences", 'u', func() { + se.showObjectList(se.sequenceKind()) + }). + AddItem("Manage Scripts", "View, create, edit, and delete SQL scripts", 'c', func() { + se.showObjectList(se.scriptKind()) + }). AddItem("Import & Merge", "Import and merge schema from another database", 'i', func() { se.showImportScreen() }). diff --git a/pkg/ui/object_dataops.go b/pkg/ui/object_dataops.go new file mode 100644 index 0000000..31418db --- /dev/null +++ b/pkg/ui/object_dataops.go @@ -0,0 +1,263 @@ +package ui + +import ( + "errors" + "fmt" + "strings" + + "git.warky.dev/wdevs/relspecgo/pkg/models" +) + +// Data operations for indexes, views, sequences, scripts and domain/table assignment. + +func (se *SchemaEditor) schemaAt(schemaIndex int) (*models.Schema, error) { + if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) { + return nil, errors.New("schema not found") + } + return se.db.Schemas[schemaIndex], nil +} + +func (se *SchemaEditor) tableAt(schemaIndex, tableIndex int) (*models.Schema, *models.Table, error) { + schema, err := se.schemaAt(schemaIndex) + if err != nil { + return nil, nil, err + } + if tableIndex < 0 || tableIndex >= len(schema.Tables) { + return nil, nil, errors.New("table not found") + } + return schema, schema.Tables[tableIndex], nil +} + +// splitList splits a comma separated list, trimming blanks and dropping empty entries. +func splitList(s string) []string { + parts := make([]string, 0) + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + parts = append(parts, p) + } + } + return parts +} + +// SaveIndex adds an index to a table. When oldName is non-empty the index of that +// name is replaced (and renamed if needed). +func (se *SchemaEditor) SaveIndex(schemaIndex, tableIndex int, oldName string, idx *models.Index) error { + schema, table, err := se.tableAt(schemaIndex, tableIndex) + if err != nil { + return err + } + idx.Name = strings.TrimSpace(idx.Name) + if idx.Name == "" { + return errors.New("index name is required") + } + if len(idx.Columns) == 0 { + return errors.New("index needs at least one column") + } + for _, c := range idx.Columns { + if _, ok := table.Columns[c]; !ok { + return fmt.Errorf("column %q not found in table %s", c, table.Name) + } + } + if _, exists := table.Indexes[idx.Name]; exists && idx.Name != oldName { + return fmt.Errorf("index %q already exists", idx.Name) + } + if table.Indexes == nil { + table.Indexes = make(map[string]*models.Index) + } + if oldName != "" { + delete(table.Indexes, oldName) + } + idx.Table = table.Name + idx.Schema = schema.Name + table.Indexes[idx.Name] = idx + table.UpdateDate() + se.db.UpdateDate() + return nil +} + +// DeleteIndex removes an index from a table. +func (se *SchemaEditor) DeleteIndex(schemaIndex, tableIndex int, name string) bool { + _, table, err := se.tableAt(schemaIndex, tableIndex) + if err != nil { + return false + } + if _, ok := table.Indexes[name]; !ok { + return false + } + delete(table.Indexes, name) + table.UpdateDate() + se.db.UpdateDate() + return true +} + +// SaveView adds a view to a schema, or replaces the one at position at (use -1 to add). +func (se *SchemaEditor) SaveView(schemaIndex, at int, v *models.View) error { + schema, err := se.schemaAt(schemaIndex) + if err != nil { + return err + } + v.Name = strings.TrimSpace(v.Name) + if v.Name == "" { + return errors.New("view name is required") + } + if strings.TrimSpace(v.Definition) == "" { + return errors.New("view definition is required") + } + for i, o := range schema.Views { + if i != at && o.Name == v.Name { + return fmt.Errorf("view %q already exists", v.Name) + } + } + v.Schema = schema.Name + if at >= 0 && at < len(schema.Views) { + schema.Views[at] = v + } else { + schema.Views = append(schema.Views, v) + } + schema.UpdateDate() + se.db.UpdateDate() + return nil +} + +// DeleteView removes the view at position at. +func (se *SchemaEditor) DeleteView(schemaIndex, at int) bool { + schema, err := se.schemaAt(schemaIndex) + if err != nil || at < 0 || at >= len(schema.Views) { + return false + } + schema.Views = append(schema.Views[:at], schema.Views[at+1:]...) + schema.UpdateDate() + se.db.UpdateDate() + return true +} + +// SaveSequence adds a sequence to a schema, or replaces the one at position at (use -1 to add). +func (se *SchemaEditor) SaveSequence(schemaIndex, at int, s *models.Sequence) error { + schema, err := se.schemaAt(schemaIndex) + if err != nil { + return err + } + s.Name = strings.TrimSpace(s.Name) + if s.Name == "" { + return errors.New("sequence name is required") + } + if s.IncrementBy == 0 { + return errors.New("increment must not be zero") + } + for i, o := range schema.Sequences { + if i != at && o.Name == s.Name { + return fmt.Errorf("sequence %q already exists", s.Name) + } + } + s.Schema = schema.Name + if at >= 0 && at < len(schema.Sequences) { + schema.Sequences[at] = s + } else { + schema.Sequences = append(schema.Sequences, s) + } + schema.UpdateDate() + se.db.UpdateDate() + return nil +} + +// DeleteSequence removes the sequence at position at. +func (se *SchemaEditor) DeleteSequence(schemaIndex, at int) bool { + schema, err := se.schemaAt(schemaIndex) + if err != nil || at < 0 || at >= len(schema.Sequences) { + return false + } + schema.Sequences = append(schema.Sequences[:at], schema.Sequences[at+1:]...) + schema.UpdateDate() + se.db.UpdateDate() + return true +} + +// SaveScript adds a script to a schema, or replaces the one at position at (use -1 to add). +func (se *SchemaEditor) SaveScript(schemaIndex, at int, s *models.Script) error { + schema, err := se.schemaAt(schemaIndex) + if err != nil { + return err + } + s.Name = strings.TrimSpace(s.Name) + if s.Name == "" { + return errors.New("script name is required") + } + if strings.TrimSpace(s.SQL) == "" { + return errors.New("script SQL is required") + } + for i, o := range schema.Scripts { + if i != at && o.Name == s.Name { + return fmt.Errorf("script %q already exists", s.Name) + } + } + s.Schema = schema.Name + if at >= 0 && at < len(schema.Scripts) { + schema.Scripts[at] = s + } else { + schema.Scripts = append(schema.Scripts, s) + } + schema.UpdateDate() + se.db.UpdateDate() + return nil +} + +// DeleteScript removes the script at position at. +func (se *SchemaEditor) DeleteScript(schemaIndex, at int) bool { + schema, err := se.schemaAt(schemaIndex) + if err != nil || at < 0 || at >= len(schema.Scripts) { + return false + } + schema.Scripts = append(schema.Scripts[:at], schema.Scripts[at+1:]...) + schema.UpdateDate() + se.db.UpdateDate() + return true +} + +// AssignTableToDomain adds a reference to schemaName.tableName to the domain at domainIndex. +func (se *SchemaEditor) AssignTableToDomain(domainIndex int, schemaName, tableName string) error { + if domainIndex < 0 || domainIndex >= len(se.db.Domains) { + return errors.New("domain not found") + } + domain := se.db.Domains[domainIndex] + var table *models.Table + for _, s := range se.db.Schemas { + if s.Name != schemaName { + continue + } + for _, t := range s.Tables { + if t.Name == tableName { + table = t + } + } + } + if table == nil { + return fmt.Errorf("table %s.%s not found", schemaName, tableName) + } + for _, dt := range domain.Tables { + if dt.SchemaName == schemaName && dt.TableName == tableName { + return fmt.Errorf("table %s.%s is already in domain %s", schemaName, tableName, domain.Name) + } + } + dt := models.InitDomainTable(tableName, schemaName) + dt.RefTable = table + dt.Sequence = uint(len(domain.Tables)) + domain.Tables = append(domain.Tables, dt) + se.db.UpdateDate() + return nil +} + +// UnassignTableFromDomain removes the reference to schemaName.tableName from the domain. +func (se *SchemaEditor) UnassignTableFromDomain(domainIndex int, schemaName, tableName string) bool { + if domainIndex < 0 || domainIndex >= len(se.db.Domains) { + return false + } + domain := se.db.Domains[domainIndex] + for i, dt := range domain.Tables { + if dt.SchemaName == schemaName && dt.TableName == tableName { + domain.Tables = append(domain.Tables[:i], domain.Tables[i+1:]...) + se.db.UpdateDate() + return true + } + } + return false +} diff --git a/pkg/ui/object_dataops_test.go b/pkg/ui/object_dataops_test.go new file mode 100644 index 0000000..fcf5675 --- /dev/null +++ b/pkg/ui/object_dataops_test.go @@ -0,0 +1,136 @@ +package ui + +import ( + "testing" + + "git.warky.dev/wdevs/relspecgo/pkg/models" +) + +func newTestEditor() *SchemaEditor { + db := models.InitDatabase("test") + schema := models.InitSchema("public") + table := models.InitTable("users", "public") + table.Columns["id"] = models.InitColumn("id", "users", "public") + table.Columns["email"] = models.InitColumn("email", "users", "public") + schema.Tables = append(schema.Tables, table) + db.Schemas = append(db.Schemas, schema) + return &SchemaEditor{db: db} +} + +func TestSaveIndex(t *testing.T) { + se := newTestEditor() + table := se.db.Schemas[0].Tables[0] + + tests := []struct { + name string + old string + idx *models.Index + wantErr bool + }{ + {"valid", "", &models.Index{Name: "idx_email", Columns: []string{"email"}, Unique: true}, false}, + {"duplicate", "", &models.Index{Name: "idx_email", Columns: []string{"email"}}, true}, + {"missing name", "", &models.Index{Columns: []string{"email"}}, true}, + {"no columns", "", &models.Index{Name: "idx_none"}, true}, + {"unknown column", "", &models.Index{Name: "idx_bad", Columns: []string{"nope"}}, true}, + {"rename", "idx_email", &models.Index{Name: "idx_email2", Columns: []string{"email", "id"}}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := se.SaveIndex(0, 0, tt.old, tt.idx); (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tt.wantErr) + } + }) + } + if _, ok := table.Indexes["idx_email"]; ok { + t.Error("renamed index should be gone under old name") + } + if idx := table.Indexes["idx_email2"]; idx == nil || idx.Table != "users" || idx.Schema != "public" { + t.Errorf("unexpected renamed index: %+v", idx) + } + if !se.DeleteIndex(0, 0, "idx_email2") || se.DeleteIndex(0, 0, "idx_email2") { + t.Error("delete should succeed once") + } +} + +func TestSaveViewSequenceScript(t *testing.T) { + se := newTestEditor() + schema := se.db.Schemas[0] + + if err := se.SaveView(0, -1, &models.View{Name: "v", Definition: "select 1"}); err != nil { + t.Fatal(err) + } + if err := se.SaveView(0, -1, &models.View{Name: "v", Definition: "select 2"}); err == nil { + t.Error("duplicate view accepted") + } + if err := se.SaveView(0, 0, &models.View{Name: "v", Definition: "select 3"}); err != nil { + t.Errorf("editing in place should not conflict: %v", err) + } + if err := se.SaveView(0, -1, &models.View{Name: "w"}); err == nil { + t.Error("view without definition accepted") + } + if len(schema.Views) != 1 || schema.Views[0].Definition != "select 3" || schema.Views[0].Schema != "public" { + t.Errorf("unexpected views: %+v", schema.Views) + } + if !se.DeleteView(0, 0) || se.DeleteView(0, 0) { + t.Error("view delete mismatch") + } + + if err := se.SaveSequence(0, -1, &models.Sequence{Name: "s", IncrementBy: 1, StartValue: 1}); err != nil { + t.Fatal(err) + } + if err := se.SaveSequence(0, -1, &models.Sequence{Name: "z"}); err == nil { + t.Error("zero increment accepted") + } + if !se.DeleteSequence(0, 0) || len(schema.Sequences) != 0 { + t.Error("sequence delete failed") + } + + if err := se.SaveScript(0, -1, &models.Script{Name: "init", SQL: "select 1"}); err != nil { + t.Fatal(err) + } + if err := se.SaveScript(0, -1, &models.Script{Name: "empty"}); err == nil { + t.Error("script without SQL accepted") + } + if err := se.SaveScript(5, -1, &models.Script{Name: "x", SQL: "y"}); err == nil { + t.Error("bad schema index accepted") + } + if !se.DeleteScript(0, 0) || len(schema.Scripts) != 0 { + t.Error("script delete failed") + } +} + +func TestDomainTableAssignment(t *testing.T) { + se := newTestEditor() + se.createDomainNoUI("core") + + if err := se.AssignTableToDomain(0, "public", "users"); err != nil { + t.Fatal(err) + } + if err := se.AssignTableToDomain(0, "public", "users"); err == nil { + t.Error("duplicate assignment accepted") + } + if err := se.AssignTableToDomain(0, "public", "missing"); err == nil { + t.Error("unknown table accepted") + } + if err := se.AssignTableToDomain(3, "public", "users"); err == nil { + t.Error("bad domain index accepted") + } + dt := se.db.Domains[0].Tables[0] + if dt.RefTable != se.db.Schemas[0].Tables[0] { + t.Error("RefTable not linked") + } + if !se.UnassignTableFromDomain(0, "public", "users") || se.UnassignTableFromDomain(0, "public", "users") { + t.Error("unassign mismatch") + } +} + +func (se *SchemaEditor) createDomainNoUI(name string) { + se.db.Domains = append(se.db.Domains, models.InitDomain(name)) +} + +func TestSplitList(t *testing.T) { + got := splitList(" a, b,, c ,") + if len(got) != 3 || got[0] != "a" || got[2] != "c" { + t.Errorf("got %v", got) + } +} diff --git a/pkg/ui/object_screens.go b/pkg/ui/object_screens.go new file mode 100644 index 0000000..631989e --- /dev/null +++ b/pkg/ui/object_screens.go @@ -0,0 +1,476 @@ +package ui + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "git.warky.dev/wdevs/relspecgo/pkg/models" +) + +// objectLocation identifies where a new object is created: a schema, and for indexes also a table. +type objectLocation struct { + label string + schemaIndex int + tableIndex int +} + +// objectRow is one existing object shown in an object list. +type objectRow struct { + cells []string + schemaIndex int + tableIndex int + at int // position within the schema slice (views, sequences, scripts) + name string // map key (indexes) +} + +// objectKind describes how a kind of schema object is listed and edited. +type objectKind struct { + page string + title string + singular string + headers []string + rows func() []objectRow + locations func() []objectLocation + // buildForm adds the editable fields to the form for row (nil when creating) and + // returns a function that validates and saves the values at the given location. + buildForm func(form *tview.Form, row *objectRow) func(loc objectLocation) error + remove func(row objectRow) bool +} + +func (se *SchemaEditor) schemaLocations() []objectLocation { + locs := make([]objectLocation, 0, len(se.db.Schemas)) + for si, s := range se.db.Schemas { + locs = append(locs, objectLocation{label: s.Name, schemaIndex: si, tableIndex: -1}) + } + return locs +} + +func (se *SchemaEditor) tableLocations() []objectLocation { + locs := make([]objectLocation, 0) + for si, s := range se.db.Schemas { + for ti, t := range s.Tables { + locs = append(locs, objectLocation{label: s.Name + "." + t.Name, schemaIndex: si, tableIndex: ti}) + } + } + return locs +} + +func (se *SchemaEditor) indexKind() objectKind { + return objectKind{ + page: "indexes", + title: "Manage Indexes", + singular: "Index", + headers: []string{"Name", "Schema", "Table", "Type", "Unique", "Columns"}, + locations: se.tableLocations, + rows: func() []objectRow { + var rows []objectRow + for si, s := range se.db.Schemas { + for ti, t := range s.Tables { + for _, name := range sortedKeys(t.Indexes) { + idx := t.Indexes[name] + rows = append(rows, objectRow{ + cells: []string{idx.Name, s.Name, t.Name, idx.Type, strconv.FormatBool(idx.Unique), strings.Join(idx.Columns, ",")}, + schemaIndex: si, tableIndex: ti, name: name, + }) + } + } + } + return rows + }, + buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error { + idx := models.InitIndex("", "", "") + idx.Type = "btree" + if row != nil { + idx = se.db.Schemas[row.schemaIndex].Tables[row.tableIndex].Indexes[row.name] + } + name, columns, typ, where := idx.Name, strings.Join(idx.Columns, ", "), idx.Type, idx.Where + unique := idx.Unique + form.AddInputField("Name", name, 40, nil, func(v string) { name = v }) + form.AddInputField("Columns (comma separated)", columns, 50, nil, func(v string) { columns = v }) + form.AddInputField("Type", typ, 20, nil, func(v string) { typ = v }) + form.AddCheckbox("Unique", unique, func(v bool) { unique = v }) + form.AddInputField("Where", where, 50, nil, func(v string) { where = v }) + return func(loc objectLocation) error { + oldName := "" + if row != nil { + oldName = row.name + } + next := *idx + next.Name, next.Columns, next.Type, next.Unique, next.Where = name, splitList(columns), typ, unique, where + return se.SaveIndex(loc.schemaIndex, loc.tableIndex, oldName, &next) + } + }, + remove: func(r objectRow) bool { return se.DeleteIndex(r.schemaIndex, r.tableIndex, r.name) }, + } +} + +func (se *SchemaEditor) viewKind() objectKind { + return objectKind{ + page: "views", + title: "Manage Views", + singular: "View", + headers: []string{"Name", "Schema", "Description"}, + locations: se.schemaLocations, + rows: func() []objectRow { + var rows []objectRow + for si, s := range se.db.Schemas { + for i, v := range s.Views { + rows = append(rows, objectRow{cells: []string{v.Name, s.Name, v.Description}, schemaIndex: si, at: i}) + } + } + return rows + }, + buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error { + view := models.InitView("", "") + at := -1 + if row != nil { + view, at = se.db.Schemas[row.schemaIndex].Views[row.at], row.at + } + name, desc, def := view.Name, view.Description, view.Definition + form.AddInputField("Name", name, 40, nil, func(v string) { name = v }) + form.AddInputField("Description", desc, 50, nil, func(v string) { desc = v }) + form.AddTextArea("Definition (SQL)", def, 60, 8, 0, func(v string) { def = v }) + return func(loc objectLocation) error { + next := *view + next.Name, next.Description, next.Definition = name, desc, def + return se.SaveView(loc.schemaIndex, at, &next) + } + }, + remove: func(r objectRow) bool { return se.DeleteView(r.schemaIndex, r.at) }, + } +} + +func (se *SchemaEditor) sequenceKind() objectKind { + return objectKind{ + page: "sequences", + title: "Manage Sequences", + singular: "Sequence", + headers: []string{"Name", "Schema", "Start", "Increment", "Cycle", "Description"}, + locations: se.schemaLocations, + rows: func() []objectRow { + var rows []objectRow + for si, s := range se.db.Schemas { + for i, q := range s.Sequences { + rows = append(rows, objectRow{ + cells: []string{q.Name, s.Name, strconv.FormatInt(q.StartValue, 10), strconv.FormatInt(q.IncrementBy, 10), strconv.FormatBool(q.Cycle), q.Description}, schemaIndex: si, at: i, + }) + } + } + return rows + }, + buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error { + seq := models.InitSequence("", "") + at := -1 + if row != nil { + seq, at = se.db.Schemas[row.schemaIndex].Sequences[row.at], row.at + } + name, desc := seq.Name, seq.Description + start, incr := strconv.FormatInt(seq.StartValue, 10), strconv.FormatInt(seq.IncrementBy, 10) + minV, maxV := strconv.FormatInt(seq.MinValue, 10), strconv.FormatInt(seq.MaxValue, 10) + cycle := seq.Cycle + form.AddInputField("Name", name, 40, nil, func(v string) { name = v }) + form.AddInputField("Description", desc, 50, nil, func(v string) { desc = v }) + form.AddInputField("Start", start, 20, nil, func(v string) { start = v }) + form.AddInputField("Increment", incr, 20, nil, func(v string) { incr = v }) + form.AddInputField("Min (0 = none)", minV, 20, nil, func(v string) { minV = v }) + form.AddInputField("Max (0 = none)", maxV, 20, nil, func(v string) { maxV = v }) + form.AddCheckbox("Cycle", cycle, func(v bool) { cycle = v }) + return func(loc objectLocation) error { + next := *seq + next.Name, next.Description, next.Cycle = name, desc, cycle + for _, f := range []struct { + label string + text string + dst *int64 + }{{"start", start, &next.StartValue}, {"increment", incr, &next.IncrementBy}, {"min", minV, &next.MinValue}, {"max", maxV, &next.MaxValue}} { + n, err := strconv.ParseInt(strings.TrimSpace(f.text), 10, 64) + if err != nil { + return fmt.Errorf("%s must be an integer", f.label) + } + *f.dst = n + } + return se.SaveSequence(loc.schemaIndex, at, &next) + } + }, + remove: func(r objectRow) bool { return se.DeleteSequence(r.schemaIndex, r.at) }, + } +} + +func (se *SchemaEditor) scriptKind() objectKind { + return objectKind{ + page: "scripts", + title: "Manage Scripts", + singular: "Script", + headers: []string{"Name", "Schema", "Version", "Priority", "Description"}, + locations: se.schemaLocations, + rows: func() []objectRow { + var rows []objectRow + for si, s := range se.db.Schemas { + for i, sc := range s.Scripts { + rows = append(rows, objectRow{cells: []string{sc.Name, s.Name, sc.Version, strconv.Itoa(sc.Priority), sc.Description}, schemaIndex: si, at: i}) + } + } + return rows + }, + buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error { + script := models.InitScript("") + at := -1 + if row != nil { + script, at = se.db.Schemas[row.schemaIndex].Scripts[row.at], row.at + } + name, desc, version, sql, rollback := script.Name, script.Description, script.Version, script.SQL, script.Rollback + priority, runAfter := strconv.Itoa(script.Priority), strings.Join(script.RunAfter, ", ") + form.AddInputField("Name", name, 40, nil, func(v string) { name = v }) + form.AddInputField("Description", desc, 50, nil, func(v string) { desc = v }) + form.AddInputField("Version", version, 20, nil, func(v string) { version = v }) + form.AddInputField("Priority", priority, 10, nil, func(v string) { priority = v }) + form.AddInputField("Run after (comma separated)", runAfter, 50, nil, func(v string) { runAfter = v }) + form.AddTextArea("SQL", sql, 60, 8, 0, func(v string) { sql = v }) + form.AddTextArea("Rollback SQL", rollback, 60, 4, 0, func(v string) { rollback = v }) + return func(loc objectLocation) error { + prio, err := strconv.Atoi(strings.TrimSpace(priority)) + if err != nil { + return fmt.Errorf("priority must be an integer") + } + next := *script + next.Name, next.Description, next.Version, next.Priority = name, desc, version, prio + next.RunAfter, next.SQL, next.Rollback = splitList(runAfter), sql, rollback + return se.SaveScript(loc.schemaIndex, at, &next) + } + }, + remove: func(r objectRow) bool { return se.DeleteScript(r.schemaIndex, r.at) }, + } +} + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// showObjectList displays all objects of a kind across schemas. +func (se *SchemaEditor) showObjectList(k objectKind) { + flex := tview.NewFlex().SetDirection(tview.FlexRow) + title := tview.NewTextView().SetText("[::b]" + k.title).SetDynamicColors(true).SetTextAlign(tview.AlignCenter) + + table := tview.NewTable().SetBorders(true).SetSelectable(true, false).SetFixed(1, 0) + for i, h := range k.headers { + table.SetCell(0, i, tview.NewTableCell(h).SetTextColor(tcell.ColorYellow).SetSelectable(false).SetAlign(tview.AlignLeft)) + } + rows := k.rows() + for r, row := range rows { + for c, text := range row.cells { + table.SetCell(r+1, c, tview.NewTableCell(text).SetSelectable(true)) + } + } + table.SetTitle(" " + k.title[len("Manage "):] + " ").SetBorder(true).SetTitleAlign(tview.AlignLeft) + + back := func() { + se.pages.SwitchToPage("main") + se.pages.RemovePage(k.page) + } + btnNew := tview.NewButton("New " + k.singular + " [n]").SetSelectedFunc(func() { se.showObjectForm(k, nil) }) + btnBack := tview.NewButton("Back [b]").SetSelectedFunc(back) + btnNew.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyBacktab: + se.app.SetFocus(table) + return nil + case tcell.KeyTab: + se.app.SetFocus(btnBack) + return nil + } + return event + }) + btnBack.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyBacktab: + se.app.SetFocus(btnNew) + return nil + case tcell.KeyTab: + se.app.SetFocus(table) + return nil + } + return event + }) + btnFlex := tview.NewFlex().AddItem(btnNew, 0, 1, true).AddItem(btnBack, 0, 1, false) + + table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch { + case event.Key() == tcell.KeyEscape, event.Rune() == 'b': + back() + return nil + case event.Key() == tcell.KeyTab: + se.app.SetFocus(btnNew) + return nil + case event.Key() == tcell.KeyEnter: + if row, _ := table.GetSelection(); row > 0 && row <= len(rows) { + se.showObjectForm(k, &rows[row-1]) + return nil + } + case event.Rune() == 'n': + se.showObjectForm(k, nil) + return nil + } + return event + }) + + flex.AddItem(title, 1, 0, false).AddItem(table, 0, 1, true).AddItem(btnFlex, 1, 0, false) + se.pages.AddPage(k.page, flex, true, true) +} + +// showObjectForm shows the create (row == nil) or edit form for an object. +func (se *SchemaEditor) showObjectForm(k objectKind, row *objectRow) { + formPage := k.page + "-form" + form := tview.NewForm() + errView := tview.NewTextView().SetDynamicColors(true) + + locs := k.locations() + loc := objectLocation{schemaIndex: -1, tableIndex: -1} + switch { + case row != nil: + loc = objectLocation{schemaIndex: row.schemaIndex, tableIndex: row.tableIndex} + case len(locs) > 0: + loc = locs[0] + labels := make([]string, len(locs)) + for i, l := range locs { + labels[i] = l.label + } + form.AddDropDown("Location", labels, 0, func(_ string, i int) { loc = locs[i] }) + } + + save := k.buildForm(form, row) + + closeForm := func() { + se.pages.RemovePage(formPage) + se.pages.RemovePage(k.page) + se.showObjectList(k) + } + form.AddButton("Save", func() { + if err := save(loc); err != nil { + errView.SetText("[red]" + tview.Escape(err.Error())) + return + } + closeForm() + }) + if row != nil { + form.AddButton("Delete", func() { + modal := tview.NewModal(). + SetText(fmt.Sprintf("Delete %s '%s'? This action cannot be undone.", strings.ToLower(k.singular), row.cells[0])). + AddButtons([]string{"Cancel", "Delete"}). + SetDoneFunc(func(_ int, label string) { + se.pages.RemovePage(formPage + "-delete") + if label == "Delete" { + k.remove(*row) + closeForm() + } + }) + se.pages.AddAndSwitchToPage(formPage+"-delete", modal, true) + }) + } + form.AddButton("Back", closeForm) + + verb := "New" + if row != nil { + verb = "Edit" + } + form.SetBorder(true).SetTitle(" " + verb + " " + k.singular + " ").SetTitleAlign(tview.AlignLeft) + form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + se.showExitConfirmation(formPage, k.page) + return nil + } + return event + }) + + if len(locs) == 0 && row == nil { + errView.SetText("[red]No schema/table available. Create one first.") + } + flex := tview.NewFlex().SetDirection(tview.FlexRow).AddItem(form, 0, 1, true).AddItem(errView, 1, 0, false) + se.pages.AddPage(formPage, flex, true, true) +} + +// showDomainTables lists the tables assigned to a domain and allows assigning/unassigning. +func (se *SchemaEditor) showDomainTables(domainIndex int) { + if domainIndex < 0 || domainIndex >= len(se.db.Domains) { + return + } + domain := se.db.Domains[domainIndex] + page := "domain-tables" + list := tview.NewList().ShowSecondaryText(true) + refresh := func() { + se.pages.RemovePage(page) + se.showDomainTables(domainIndex) + } + + for _, dt := range domain.Tables { + dt := dt + list.AddItem(dt.SchemaName+"."+dt.TableName, "Enter to remove from domain", 0, func() { + se.UnassignTableFromDomain(domainIndex, dt.SchemaName, dt.TableName) + refresh() + }) + } + list.AddItem("[Assign Table]", "Add a table to this domain", 'a', func() { + se.showAssignDomainTable(domainIndex, refresh) + }) + list.AddItem("[Back]", "Return to domain", 'b', func() { + se.pages.RemovePage(page) + }) + list.SetBorder(true).SetTitle(" Domain " + domain.Name + " - Tables ").SetTitleAlign(tview.AlignLeft) + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + se.pages.RemovePage(page) + return nil + } + return event + }) + se.pages.AddPage(page, list, true, true) +} + +// showAssignDomainTable shows a form to pick a table not yet in the domain. +func (se *SchemaEditor) showAssignDomainTable(domainIndex int, done func()) { + page := "assign-domain-table" + domain := se.db.Domains[domainIndex] + var options []string + var refs []models.DomainTable + for _, s := range se.db.Schemas { + for _, t := range s.Tables { + taken := false + for _, dt := range domain.Tables { + taken = taken || (dt.SchemaName == s.Name && dt.TableName == t.Name) + } + if !taken { + options = append(options, s.Name+"."+t.Name) + refs = append(refs, models.DomainTable{SchemaName: s.Name, TableName: t.Name}) + } + } + } + form := tview.NewForm() + selected := 0 + form.AddDropDown("Table", options, 0, func(_ string, i int) { selected = i }) + form.AddButton("Assign", func() { + if len(refs) > 0 { + _ = se.AssignTableToDomain(domainIndex, refs[selected].SchemaName, refs[selected].TableName) + } + se.pages.RemovePage(page) + done() + }) + form.AddButton("Back", func() { se.pages.RemovePage(page) }) + form.SetBorder(true).SetTitle(" Assign Table ").SetTitleAlign(tview.AlignLeft) + form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + se.pages.RemovePage(page) + return nil + } + return event + }) + se.pages.AddPage(page, form, true, true) +}