From 9f92e43b4fbb1c282231ef6b9090494d4affaf7b Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Fri, 31 Jul 2026 14:51:53 +0100 Subject: [PATCH 1/5] Add new TODOs from Nassib's Slack message --- TODO.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/TODO.md b/TODO.md index cf08395..a4ccba9 100644 --- a/TODO.md +++ b/TODO.md @@ -46,6 +46,17 @@ 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: +* Commands `show filters` and `show sets` now return 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 From c629191b3c819481977569404068a7234e01da90 Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Fri, 31 Jul 2026 16:37:54 +0100 Subject: [PATCH 2/5] Break compound TODO into two --- TODO.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index a4ccba9..e537884 100644 --- a/TODO.md +++ b/TODO.md @@ -49,7 +49,8 @@ The main change above is the `update` command which allows updating spectre attr ### Chunk 4 CCMS v0.0.29 updated in test instance: -* Commands `show filters` and `show sets` now return project name in a separate column. +* `show filters` now returns project name in a separate column. +* `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 From 53a9c330ba942a651f3ab71247e76dea32f6a9ff Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Fri, 31 Jul 2026 16:41:05 +0100 Subject: [PATCH 3/5] Handle expanded "show filters" response and return structs --- TODO.md | 2 +- cyclops/handlers.go | 27 +++++++++++++-- cyclops/handlers_test.go | 44 ++++++++++++++++++++++-- ramls/cyclops.raml | 2 +- ramls/examples/show-filters-example.json | 12 +++++-- ramls/filters-schema.json | 28 ++++++++++++--- 6 files changed, 102 insertions(+), 13 deletions(-) diff --git a/TODO.md b/TODO.md index e537884..d01d6ae 100644 --- a/TODO.md +++ b/TODO.md @@ -49,7 +49,7 @@ The main change above is the `update` command which allows updating spectre attr ### Chunk 4 CCMS v0.0.29 updated in test instance: -* `show filters` now returns project name in a separate column. +* **DONE** `show filters` now returns project name in a separate column. * `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 diff --git a/cyclops/handlers.go b/cyclops/handlers.go index c0a67d2..3137785 100644 --- a/cyclops/handlers.go +++ b/cyclops/handlers.go @@ -63,8 +63,14 @@ func (server *ModCyclopsServer) handleDefineTag(w http.ResponseWriter, req *http // ----------------------------------------------------------------------------- +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 +81,24 @@ func (server *ModCyclopsServer) handleShowFilters(w http.ResponseWriter, req *ht } result := readResults(resp)[0] - filters := make([]any, 0) + index := make(map[string]int, len(result.Fields())) + for i, field := range result.Fields() { + index[field.Name()] = i + } + for _, name := range []string{"project", "filter", "definition"} { + if _, ok := index[name]; !ok { + return fmt.Errorf("%s: no '%s' field in response", caption, name) + } + } + + 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) diff --git a/cyclops/handlers_test.go b/cyclops/handlers_test.go index d39ddfa..bb35fb6 100644 --- a/cyclops/handlers_test.go +++ b/cyclops/handlers_test.go @@ -232,8 +232,27 @@ func TestHandleShowTags(t *testing.T) { } } +// filterResponse builds the three-column "ok" response that CCMS now returns +// for "show filters". The fields are deliberately not in the order of the +// FilterSummary structure, since the handler keys them by name. +func filterResponse(rows ...[]any) *ccms.Response { + result := ccms.NewResult("ok") + result.AddField("filter", "text") + result.AddField("definition", "text") + result.AddField("project", "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: filterResponse( + []any{"active", "age > 18", "PROJ"}, + []any{"archived", "status = 'old'", "OTHER"}, + )} server := newTestServer(fake) rr := httptest.NewRecorder() @@ -249,12 +268,33 @@ 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) { + result := ccms.NewResult("ok") + result.AddField("project", "text") + result.AddField("filter", "text") + result.AddData([]any{"PROJ", "active"}) + resp := ccms.NewResponse() + resp.AddResult(result) + + server := newTestServer(&fakeCCMS{resp: resp}) + + 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")} server := newTestServer(fake) diff --git a/ramls/cyclops.raml b/ramls/cyclops.raml index 575261e..53ef149 100644 --- a/ramls/cyclops.raml +++ b/ramls/cyclops.raml @@ -43,7 +43,7 @@ documentation: /filters: description: "Filters that can narrow down records within a set" get: - description: "Return a list of all known filters" + description: "Return a list of all known filters, with their projects and definitions" responses: 200: body: diff --git a/ramls/examples/show-filters-example.json b/ramls/examples/show-filters-example.json index b2033f3..f5e8ac2 100644 --- a/ramls/examples/show-filters-example.json +++ b/ramls/examples/show-filters-example.json @@ -1,6 +1,14 @@ { "filters": [ - "jurassic", - "cretaceous" + { + "project": "korea_lit", + "filter": "jurassic", + "definition": "143100000 <= age AND age <= 201400000" + }, + { + "project": "korea_lit", + "filter": "cretaceous", + "definition": "66000000 <= age AND age <= 143100000" + } ] } diff --git a/ramls/filters-schema.json b/ramls/filters-schema.json index c38af19..23fa3be 100644 --- a/ramls/filters-schema.json +++ b/ramls/filters-schema.json @@ -1,14 +1,34 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "description": "An object containing a list of zero or more filter names", + "description": "An object containing a list of zero or more filters", "type": "object", "properties": { "filters": { - "description": "The names of the known filters (the 'cond' and 'template' of each filter are not included in this listing)", + "description": "The known filters, each with the project that it belongs to", "type": "array", "items": { - "type": "string", - "description": "The short human-readable name of a filter" + "type": "object", + "description": "A filter, in the project that it belongs to", + "properties": { + "project": { + "type": "string", + "description": "The name of the project that the filter belongs to" + }, + "filter": { + "type": "string", + "description": "The short name of the filter, unique within its project" + }, + "definition": { + "type": "string", + "description": "The definition of the filter, an SQL-like WHERE condition" + } + }, + "additionalProperties": false, + "required": [ + "project", + "filter", + "definition" + ] } } }, From 69be706a9c63aedcadbd2cded1fce602da0c5fab Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Fri, 31 Jul 2026 16:41:29 +0100 Subject: [PATCH 4/5] create-filter exercises use a faceted name --- htdocs/index.html | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 @@
  • --> -
      -
    • list of filters
    • -
    • -
    • - create filter decided where decision = true -
    +
      +
    • list of filters
    • +
    • +
    • + create filter decided where decision = true +
    • list of sets
    • From 5529f831764d70a5d026ea482d545043dc17c87f Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Fri, 31 Jul 2026 18:22:06 +0100 Subject: [PATCH 5/5] `show sets` now returns project name in a separate column. --- TODO.md | 2 +- cyclops/handlers.go | 73 ++++++++++++++++++++------- cyclops/handlers_test.go | 70 +++++++++++++++++-------- ramls/cyclops.raml | 4 +- ramls/examples/show-sets-example.json | 18 +++++-- ramls/sets-schema.json | 28 ++++++++-- 6 files changed, 145 insertions(+), 50 deletions(-) diff --git a/TODO.md b/TODO.md index d01d6ae..8e1cc64 100644 --- a/TODO.md +++ b/TODO.md @@ -50,7 +50,7 @@ The main change above is the `update` command which allows updating spectre attr CCMS v0.0.29 updated in test instance: * **DONE** `show filters` now returns project name in a separate column. -* `show sets` 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 diff --git a/cyclops/handlers.go b/cyclops/handlers.go index 3137785..0fa62b3 100644 --- a/cyclops/handlers.go +++ b/cyclops/handlers.go @@ -63,6 +63,24 @@ 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"` @@ -81,14 +99,9 @@ func (server *ModCyclopsServer) handleShowFilters(w http.ResponseWriter, req *ht } result := readResults(resp)[0] - index := make(map[string]int, len(result.Fields())) - for i, field := range result.Fields() { - index[field.Name()] = i - } - for _, name := range []string{"project", "filter", "definition"} { - if _, ok := index[name]; !ok { - return fmt.Errorf("%s: no '%s' field in response", caption, name) - } + index, err := fieldIndex(result, "project", "filter", "definition") + if err != nil { + return fmt.Errorf("%s: %w", caption, err) } filters := make([]FilterSummary, 0) @@ -149,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) } @@ -995,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 bb35fb6..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,14 +232,15 @@ func TestHandleShowTags(t *testing.T) { } } -// filterResponse builds the three-column "ok" response that CCMS now returns -// for "show filters". The fields are deliberately not in the order of the -// FilterSummary structure, since the handler keys them by name. -func filterResponse(rows ...[]any) *ccms.Response { +// 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") - result.AddField("filter", "text") - result.AddField("definition", "text") - result.AddField("project", "text") + for _, field := range fields { + result.AddField(field, "text") + } for _, row := range rows { result.AddData(row) } @@ -249,7 +250,8 @@ func filterResponse(rows ...[]any) *ccms.Response { } func TestHandleShowFilters(t *testing.T) { - fake := &fakeCCMS{resp: filterResponse( + fake := &fakeCCMS{resp: namedResponse( + []string{"filter", "definition", "project"}, []any{"active", "age > 18", "PROJ"}, []any{"archived", "status = 'old'", "OTHER"}, )} @@ -278,14 +280,11 @@ func TestHandleShowFilters(t *testing.T) { } func TestHandleShowFiltersMissingField(t *testing.T) { - result := ccms.NewResult("ok") - result.AddField("project", "text") - result.AddField("filter", "text") - result.AddData([]any{"PROJ", "active"}) - resp := ccms.NewResponse() - resp.AddResult(result) - - server := newTestServer(&fakeCCMS{resp: resp}) + fake := &fakeCCMS{resp: namedResponse( + []string{"project", "filter"}, + []any{"PROJ", "active"}, + )} + server := newTestServer(fake) rr := httptest.NewRecorder() err := server.handleShowFilters(rr, jsonRequest("", nil), "show filters") @@ -296,7 +295,11 @@ func TestHandleShowFiltersMissingField(t *testing.T) { } 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() @@ -312,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() @@ -335,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/ramls/cyclops.raml b/ramls/cyclops.raml index 53ef149..a7faf52 100644 --- a/ramls/cyclops.raml +++ b/ramls/cyclops.raml @@ -62,7 +62,7 @@ documentation: /sets: description: "Sets of objects (books, films, etc.)" get: - description: "Return a list of the names of all known sets" + description: "Return a list of all known sets, with their projects and titles" responses: 200: body: @@ -250,7 +250,7 @@ documentation: /sets: description: "Sets of objects (books, films, etc.) in this project" get: - description: "Return a list of the names of all known sets in this project" + description: "Return a list of all known sets in this project, with their titles" responses: 200: body: diff --git a/ramls/examples/show-sets-example.json b/ramls/examples/show-sets-example.json index 7f9c248..afd2499 100644 --- a/ramls/examples/show-sets-example.json +++ b/ramls/examples/show-sets-example.json @@ -1,7 +1,19 @@ { "sets": [ - "mike", - "test", - "uob" + { + "project": "korea_lit", + "set": "mike", + "title": "Mike's working set" + }, + { + "project": "korea_lit", + "set": "test", + "title": "Test data" + }, + { + "project": "dinosaurs", + "set": "uob", + "title": "Holdings of the University of Bath" + } ] } diff --git a/ramls/sets-schema.json b/ramls/sets-schema.json index c078fcb..001388a 100644 --- a/ramls/sets-schema.json +++ b/ramls/sets-schema.json @@ -1,14 +1,34 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "description": "An object containing a list of zero or more set names", + "description": "An object containing a list of zero or more sets", "type": "object", "properties": { "sets": { - "description": "The names of the known sets", + "description": "The known sets, each with the project that it belongs to", "type": "array", "items": { - "type": "string", - "description": "The short human-readable name of a set" + "type": "object", + "description": "A set, in the project that it belongs to", + "properties": { + "project": { + "type": "string", + "description": "The name of the project that the set belongs to" + }, + "set": { + "type": "string", + "description": "The short name of the set, unique within its project" + }, + "title": { + "type": "string", + "description": "The human-readable title of the set" + } + }, + "additionalProperties": false, + "required": [ + "project", + "set", + "title" + ] } } },