Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 70 additions & 14 deletions cyclops/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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)
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand Down
82 changes: 75 additions & 7 deletions cyclops/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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)
}
Expand Down
12 changes: 6 additions & 6 deletions htdocs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,6 @@
<li><button class="link-button" onclick="postData('/cyclops/tags', { name: 'dino' })">define tag "dino"</button></li>
</ul>
-->
<ul>
<li><a href="/cyclops/filters">list of filters</a></li>
<li><button class="link-button" onclick="postData('/cyclops/filters', { name: 'jurassic', cond: '143100000 <= age AND age <= 201400000', __UNUSED_template: 'mesozoic' })">create filter "jurassic"</button></li>
<li><button class="link-button" onclick="postData('/cyclops/filters', { name: 'decided', cond: 'decision = true' })">create filter "decided"</button></li>
create filter decided where decision = true
</ul>
<ul>
<li><a href="/cyclops/funds">list of funds</a></li>
<li><button class="link-button" onclick="postData('/cyclops/funds', { id: 'palci_development', name: 'PALCI development fund' })">define fund "palci_development"</button></li>
Expand All @@ -73,6 +67,12 @@
})">modify project "literature of North Korea"</button></li>
<li><a href="/cyclops/projects/korea_lit/sets">list of sets in project "literature of North Korea"</a></li>
</ul>
<ul>
<li><a href="/cyclops/filters">list of filters</a></li>
<li><button class="link-button" onclick="postData('/cyclops/filters', { name: 'korea_lit.jurassic', cond: '143100000 <= age AND age <= 201400000', __UNUSED_template: 'mesozoic' })">create filter "jurassic" in project "literature of North Korea"</button></li>
<li><button class="link-button" onclick="postData('/cyclops/filters', { name: 'korea_lit.decided', cond: 'decision = true' })">create filter "decided" in project "literature of North Korea"</button></li>
create filter decided where decision = true
</ul>
<ul>
<li><a href="/cyclops/sets">list of sets</a></li>
<li><button class="link-button" onclick="postData('/cyclops/sets', { name: 'korea_lit.mike' })">create set "korea_lit.mike"</button></li>
Expand Down
6 changes: 3 additions & 3 deletions ramls/cyclops.raml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 10 additions & 2 deletions ramls/examples/show-filters-example.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
18 changes: 15 additions & 3 deletions ramls/examples/show-sets-example.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Loading