Added api/classifications endpoint, requiring a new query

This commit is contained in:
bwilliams committed 2026-10-16 14:44:12 +01:00
1 parent 378ac59f41
commit 0e27d2b2f3
9 files changed
+186 -6

No files matched your search

+14 -1
View File
@@ -75,7 +75,9 @@ SELECT t.* FROM opportunities t WHERE t.uuid = ?`
// ListOpportunities is assembled by listOpportunitiesQuery as:
//
// listOpportunitiesSelect [WHERE filter AND filter ...] listOpportunitiesOrderAndPage
// listOpportunitiesSelect
// [WHERE filter AND filter ...]
// listOpportunitiesOrderAndPage
//
// Each filterOpportunity* fragment takes exactly one "?" argument, except
// filterOpportunityMark* fragments, which take none. A mark fragment is true
@@ -213,6 +215,17 @@ INSERT INTO opportunity_classifications (opportunity_uuid, classification_id)
VALUES (?, ?)
ON CONFLICT DO NOTHING`
// selectClassificationsInUse reads every classification code that at least
// one opportunity has, once each. c.* must map entirely onto
// types.ClassificationCode.
const selectClassificationsInUse = `
SELECT c.* FROM classification_codes c
WHERE EXISTS (
SELECT 1 FROM opportunity_classifications tc
WHERE tc.classification_id = c.id
)
ORDER BY c.code, c.id`
// selectClassificationsForOpportunities reads the classifications of several
// opportunities. "IN (?)" is expanded by sqlx.In, which must be followed by
// Rebind. c.* must map entirely onto types.ClassificationCode.
+3
View File
@@ -100,6 +100,9 @@ var checks = map[string]queryCheck{
"insertOpportunityClassification": {
sql: insertOpportunityClassification,
},
"selectClassificationsInUse": {
sql: selectClassificationsInUse,
},
"selectClassificationsForOpportunities": {
sql: selectClassificationsForOpportunities,
in: true,
+15
View File
@@ -717,6 +717,21 @@ func (s *Store) ListReleases(
return releases, nil
}
// ListClassifications returns every classification code that at least one
// stored opportunity has, ordered by code. A code no opportunity has any more
// isn't listed. The result is never nil.
func (s *Store) ListClassifications(
ctx context.Context,
) ([]types.ClassificationCode, error) {
classifications := []types.ClassificationCode{}
err := s.db.SelectContext(ctx, &classifications, selectClassificationsInUse)
if err != nil {
return nil, fmt.Errorf("listing classifications: %w", err)
}
return classifications, nil
}
// LatestReleaseDate returns the date of the newest release stored for the
// opportunities from source, or the zero time if none are stored, so a
// fetcher can carry on from where it left off.
+42
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
@@ -902,3 +903,44 @@ func TestSaveResultNewOpportunity(t *testing.T) {
)
}
}
// ListClassifications lists the codes in use, once each and sorted, and not
// a code no opportunity has any more.
func TestListClassifications(t *testing.T) {
s := openStore(t)
ctx := context.Background()
got, err := s.ListClassifications(ctx)
if err != nil || got == nil || len(got) != 0 {
t.Fatalf("empty store: %v, %v; want an empty, non-nil list", got, err)
}
mustSave(t, s, opportunity("ocds-1", "First", 1, "r1"))
mustSave(t, s, opportunity("ocds-2", "Second", 1, "r1"))
codes := func() []string {
t.Helper()
got, err := s.ListClassifications(ctx)
if err != nil {
t.Fatal(err)
}
var listed []string
for _, classification := range got {
listed = append(listed, classification.Code)
}
return listed
}
if got := strings.Join(codes(), ","); got != "03000000,72000000" {
t.Fatalf("codes = %s; want 03000000,72000000", got)
}
// Newer releases replace both opportunities' classifications with one
// code; the other is still stored, but no longer listed.
for _, uuid := range []string{"ocds-1", "ocds-2"} {
newer := opportunity(uuid, "Newer", 2, "r2")
newer.Classifications = newer.Classifications[1:]
mustSave(t, s, newer)
}
if got := strings.Join(codes(), ","); got != "03000000" {
t.Errorf("after the change: codes = %s; want 03000000", got)
}
}