diff --git a/TODO.md b/TODO.md index cf08395..8e1cc64 100644 --- a/TODO.md +++ b/TODO.md @@ -46,6 +46,18 @@ The main change above is the `update` command which allows updating spectre attr * **DONE** The “create filter” and “show filters” commands and the “filter()” operator are available in test/demo. See documentation for more details. +### Chunk 4 + +CCMS v0.0.29 updated in test instance: +* **DONE** `show filters` now returns project name in a separate column. +* **DONE** `show sets` now returns project name in a separate column. +* Filters now have a project name space. https://d1f3dtrg62pav.cloudfront.net/ccms/doc/current/#_create_filter +* Added command `show filters in project`. https://d1f3dtrg62pav.cloudfront.net/ccms/doc/current/#_show +* Added command `drop filter`. https://d1f3dtrg62pav.cloudfront.net/ccms/doc/current/#_drop_filter +* Added `cascade` option to the command `drop project`. https://d1f3dtrg62pav.cloudfront.net/ccms/doc/current/#_drop_project +* **DONE** Added the attribute `holdings_count`. https://d1f3dtrg62pav.cloudfront.net/ccms/doc/current/#_attributes + + ### For me * **DONE** Protect most generated SQL-like commands from injection diff --git a/cyclops/handlers.go b/cyclops/handlers.go index c0a67d2..0fa62b3 100644 --- a/cyclops/handlers.go +++ b/cyclops/handlers.go @@ -63,8 +63,32 @@ func (server *ModCyclopsServer) handleDefineTag(w http.ResponseWriter, req *http // ----------------------------------------------------------------------------- +// fieldIndex maps each of the named fields to the column it occupies in the +// result, so that handlers need not rely on CCMS returning them in any +// particular order. It fails unless all the named fields are present. +func fieldIndex(result ccms.Result, names ...string) (map[string]int, error) { + index := make(map[string]int, len(result.Fields())) + for i, field := range result.Fields() { + index[field.Name()] = i + } + for _, name := range names { + if _, ok := index[name]; !ok { + return nil, fmt.Errorf("no '%s' field in response", name) + } + } + return index, nil +} + +// ----------------------------------------------------------------------------- + +type FilterSummary struct { + Project string `json:"project"` + Filter string `json:"filter"` + Definition string `json:"definition"` +} + type FilterList struct { - Filters []any `json:"filters"` + Filters []FilterSummary `json:"filters"` // No other elements yet, but use a structure for future expansion } @@ -75,9 +99,19 @@ func (server *ModCyclopsServer) handleShowFilters(w http.ResponseWriter, req *ht } result := readResults(resp)[0] - filters := make([]any, 0) + index, err := fieldIndex(result, "project", "filter", "definition") + if err != nil { + return fmt.Errorf("%s: %w", caption, err) + } + + filters := make([]FilterSummary, 0) for val := range result.Data() { - filters = append(filters, val.Values()[0]) + values := val.Values() + filters = append(filters, FilterSummary{ + Project: mustString(values[index["project"]]), + Filter: mustString(values[index["filter"]]), + Definition: mustString(values[index["definition"]]), + }) } filterList := FilterList{Filters: filters} return server.respondWithJSON(w, filterList, caption) @@ -128,23 +162,47 @@ func (server *ModCyclopsServer) handleCreateFilter(w http.ResponseWriter, req *h // ----------------------------------------------------------------------------- +type SetSummary struct { + Project string `json:"project"` + Set string `json:"set"` + Title string `json:"title"` +} + type SetList struct { - Sets []any `json:"sets"` + Sets []SetSummary `json:"sets"` // No other elements yet, but use a structure for future expansion } +// readSetList translates the three-column response that CCMS returns for the +// "show sets" commands, keying the columns by name rather than by position. +func readSetList(result ccms.Result) (SetList, error) { + index, err := fieldIndex(result, "project", "set", "title") + if err != nil { + return SetList{}, err + } + + sets := make([]SetSummary, 0) + for val := range result.Data() { + values := val.Values() + sets = append(sets, SetSummary{ + Project: mustString(values[index["project"]]), + Set: mustString(values[index["set"]]), + Title: mustString(values[index["title"]]), + }) + } + return SetList{Sets: sets}, nil +} + func (server *ModCyclopsServer) handleShowSets(w http.ResponseWriter, req *http.Request, caption string) error { resp, err := server.sendToCCMS(caption, "show sets;") if err != nil { return fmt.Errorf("could not fetch show-sets response: %w", err) } - result := readResults(resp)[0] - sets := make([]any, 0) - for val := range result.Data() { - sets = append(sets, val.Values()[0]) + setList, err := readSetList(readResults(resp)[0]) + if err != nil { + return fmt.Errorf("%s: %w", caption, err) } - setList := SetList{Sets: sets} return server.respondWithJSON(w, setList, caption) } @@ -974,12 +1032,10 @@ func (server *ModCyclopsServer) handleShowSetsInProject(w http.ResponseWriter, r return fmt.Errorf("could not fetch show-sets response: %w", err) } - result := readResults(resp)[0] - sets := make([]any, 0) - for val := range result.Data() { - sets = append(sets, val.Values()[0]) + setList, err := readSetList(readResults(resp)[0]) + if err != nil { + return fmt.Errorf("%s: %w", caption, err) } - setList := SetList{Sets: sets} return server.respondWithJSON(w, setList, caption) } diff --git a/cyclops/handlers_test.go b/cyclops/handlers_test.go index d39ddfa..0305aee 100644 --- a/cyclops/handlers_test.go +++ b/cyclops/handlers_test.go @@ -195,7 +195,7 @@ func okResponse() *ccms.Response { } // listResponse builds an "ok" response whose single result has one data row per -// value, each a single-column string. This matches what the show* handlers read. +// value, each a single-column string. This matches what handleShowTags reads. func listResponse(values ...string) *ccms.Response { result := ccms.NewResult("ok") for _, v := range values { @@ -232,8 +232,29 @@ func TestHandleShowTags(t *testing.T) { } } +// namedResponse builds an "ok" response whose single result carries the named +// text fields and one data row per set of values. Tests that use it name the +// fields in an order other than that of the structure being built, since the +// handlers key the columns by name rather than by position. +func namedResponse(fields []string, rows ...[]any) *ccms.Response { + result := ccms.NewResult("ok") + for _, field := range fields { + result.AddField(field, "text") + } + for _, row := range rows { + result.AddData(row) + } + resp := ccms.NewResponse() + resp.AddResult(result) + return resp +} + func TestHandleShowFilters(t *testing.T) { - fake := &fakeCCMS{resp: listResponse("active", "archived")} + fake := &fakeCCMS{resp: namedResponse( + []string{"filter", "definition", "project"}, + []any{"active", "age > 18", "PROJ"}, + []any{"archived", "status = 'old'", "OTHER"}, + )} server := newTestServer(fake) rr := httptest.NewRecorder() @@ -249,14 +270,36 @@ func TestHandleShowFilters(t *testing.T) { if err != nil { t.Fatalf("could not decode response body %q: %v", rr.Body.String(), err) } - want := FilterList{Filters: []any{"active", "archived"}} + want := FilterList{Filters: []FilterSummary{ + {Project: "PROJ", Filter: "active", Definition: "age > 18"}, + {Project: "OTHER", Filter: "archived", Definition: "status = 'old'"}, + }} if !reflect.DeepEqual(got, want) { t.Errorf("translated response:\n got %+v\nwant %+v", got, want) } } +func TestHandleShowFiltersMissingField(t *testing.T) { + fake := &fakeCCMS{resp: namedResponse( + []string{"project", "filter"}, + []any{"PROJ", "active"}, + )} + server := newTestServer(fake) + + rr := httptest.NewRecorder() + err := server.handleShowFilters(rr, jsonRequest("", nil), "show filters") + if err == nil { + t.Fatal("expected an error for a response with no 'definition' field, got nil") + } + assertErrContains(t, err, "no 'definition' field") +} + func TestHandleShowSets(t *testing.T) { - fake := &fakeCCMS{resp: listResponse("users", "books")} + fake := &fakeCCMS{resp: namedResponse( + []string{"set", "title", "project"}, + []any{"users", "All our users", "PROJ"}, + []any{"books", "Books in the collection", "OTHER"}, + )} server := newTestServer(fake) rr := httptest.NewRecorder() @@ -272,14 +315,36 @@ func TestHandleShowSets(t *testing.T) { if err != nil { t.Fatalf("could not decode response body %q: %v", rr.Body.String(), err) } - want := SetList{Sets: []any{"users", "books"}} + want := SetList{Sets: []SetSummary{ + {Project: "PROJ", Set: "users", Title: "All our users"}, + {Project: "OTHER", Set: "books", Title: "Books in the collection"}, + }} if !reflect.DeepEqual(got, want) { t.Errorf("translated response:\n got %+v\nwant %+v", got, want) } } +func TestHandleShowSetsMissingField(t *testing.T) { + fake := &fakeCCMS{resp: namedResponse( + []string{"project", "set"}, + []any{"PROJ", "users"}, + )} + server := newTestServer(fake) + + rr := httptest.NewRecorder() + err := server.handleShowSets(rr, jsonRequest("", nil), "show sets") + if err == nil { + t.Fatal("expected an error for a response with no 'title' field, got nil") + } + assertErrContains(t, err, "no 'title' field") +} + func TestHandleShowSetsInProject(t *testing.T) { - fake := &fakeCCMS{resp: listResponse("mike.object", "mike.endangered")} + fake := &fakeCCMS{resp: namedResponse( + []string{"set", "title", "project"}, + []any{"object", "Objects of interest", "mike"}, + []any{"endangered", "Endangered species", "mike"}, + )} server := newTestServer(fake) rr := httptest.NewRecorder() @@ -295,7 +360,10 @@ func TestHandleShowSetsInProject(t *testing.T) { if err != nil { t.Fatalf("could not decode response body %q: %v", rr.Body.String(), err) } - want := SetList{Sets: []any{"mike.object", "mike.endangered"}} + want := SetList{Sets: []SetSummary{ + {Project: "mike", Set: "object", Title: "Objects of interest"}, + {Project: "mike", Set: "endangered", Title: "Endangered species"}, + }} if !reflect.DeepEqual(got, want) { t.Errorf("translated response:\n got %+v\nwant %+v", got, want) } diff --git a/htdocs/index.html b/htdocs/index.html index fef8caa..c764fc4 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -44,12 +44,6 @@
--> -