Added a ClassificationsUpdated event

SaveOpportunities now reports the classification codes a save brings into
use (AddedClassifications, in full) and takes out of use
(RemovedClassificationIDs), by comparing the codes in use before and after
the save inside its transaction. The fetcher publishes them as a
ClassificationsUpdated event, {"added":[codes],"removed":[ids]}, never null,
before the save's OpportunitiesUpdated, so clients can keep their list of
classifications without asking the API again.
This commit is contained in:
bwilliams committed 2026-10-16 15:24:37 +01:00
1 parent aa06b94870
commit 51d6470649
6 files changed
+287 -3

No files matched your search

+69 -1
View File
@@ -61,6 +61,14 @@ type SaveMultipleResult struct {
UpdatedOpportunities int
// OpportunityResults is a slice of the results of each opportunity saved.
OpportunityResults []SaveResult
// AddedClassifications are the classification codes that no stored
// opportunity had before the save and at least one has after it, ordered
// by code; nil if there are none.
AddedClassifications []types.ClassificationCode
// RemovedClassificationIDs are the ids of the classification codes that
// a stored opportunity had before the save and none has after it, in
// code order; nil if there are none.
RemovedClassificationIDs []int64
}
// SaveResult reports what SaveOpportunity changed.
@@ -297,7 +305,9 @@ func (s *Store) SaveOpportunity(
// SetMarks to change them).
//
// Each opportunity is normalised in place (see prepareOpportunity), and when
// it is written its classifications' IDs are filled in.
// it is written its classifications' IDs are filled in. The result reports
// the classification codes the save brought into or out of use (see
// ListClassifications).
func (s *Store) SaveOpportunities(
ctx context.Context, opportunities []*types.Opportunity,
) (SaveMultipleResult, error) {
@@ -313,6 +323,11 @@ func (s *Store) SaveOpportunities(
}
defer tx.Rollback() //nolint:errcheck // no-op after Commit
before, err := classificationsInUse(ctx, tx)
if err != nil {
return SaveMultipleResult{}, err
}
total := SaveMultipleResult{
OpportunityResults: make([]SaveResult, len(opportunities)),
}
@@ -331,6 +346,14 @@ func (s *Store) SaveOpportunities(
}
}
after, err := classificationsInUse(ctx, tx)
if err != nil {
return SaveMultipleResult{}, err
}
added, removed := classificationChanges(before, after)
total.AddedClassifications = added
total.RemovedClassificationIDs = removed
if err := tx.Commit(); err != nil {
return SaveMultipleResult{}, err
}
@@ -338,6 +361,51 @@ func (s *Store) SaveOpportunities(
return total, nil
}
// classificationsInUse returns the classification codes at least one
// opportunity has, ordered by code, as tx sees them.
func classificationsInUse(
ctx context.Context, tx *sqlx.Tx,
) ([]types.ClassificationCode, error) {
var classifications []types.ClassificationCode
err := tx.SelectContext(ctx, &classifications, selectClassificationsInUse)
if err != nil {
return nil, fmt.Errorf("listing classifications: %w", err)
}
return classifications, nil
}
// classificationChanges compares the classification codes in use before and
// after a save: it returns the codes in after but not before, and the ids of
// those in before but not after, each in the order they're given.
func classificationChanges(
before, after []types.ClassificationCode,
) ([]types.ClassificationCode, []int64) {
wasInUse := make(map[int64]bool, len(before))
for _, classification := range before {
wasInUse[classification.ID] = true
}
isInUse := make(map[int64]bool, len(after))
for _, classification := range after {
isInUse[classification.ID] = true
}
var added []types.ClassificationCode
for _, classification := range after {
if !wasInUse[classification.ID] {
added = append(added, classification)
}
}
var removed []int64
for _, classification := range before {
if !isInUse[classification.ID] {
removed = append(removed, classification.ID)
}
}
return added, removed
}
// saveOpportunity does the work of SaveOpportunity and SaveOpportunities for
// one prepared opportunity inside tx. isNew reports that the opportunity was
// not already stored.
+66
View File
@@ -944,3 +944,69 @@ func TestListClassifications(t *testing.T) {
t.Errorf("after the change: codes = %s; want 03000000", got)
}
}
// SaveOpportunities reports the classification codes a save brings into use
// and those it takes out of use, and none when the set doesn't change.
func TestSaveOpportunitiesClassificationChanges(t *testing.T) {
s := openStore(t)
ctx := context.Background()
save := func(opportunity *types.Opportunity) store.SaveMultipleResult {
t.Helper()
total, err := s.SaveOpportunities(ctx, []*types.Opportunity{opportunity})
if err != nil {
t.Fatal(err)
}
return total
}
codesOf := func(classifications []types.ClassificationCode) string {
var codes []string
for _, classification := range classifications {
codes = append(codes, classification.Code)
}
return strings.Join(codes, ",")
}
total := save(opportunity("ocds-1", "First", 1, "r1"))
if codesOf(total.AddedClassifications) != "03000000,72000000" ||
total.RemovedClassificationIDs != nil {
t.Fatalf(
"first save: added %v, removed %v; want both codes added",
total.AddedClassifications, total.RemovedClassificationIDs,
)
}
removedID := total.AddedClassifications[1].ID // 72000000
total = save(opportunity("ocds-2", "Second", 1, "r1"))
if total.AddedClassifications != nil ||
total.RemovedClassificationIDs != nil {
t.Errorf(
"codes already in use: added %v, removed %v; want none",
total.AddedClassifications, total.RemovedClassificationIDs,
)
}
// ocds-1 drops 72000000, which ocds-2 still has: still in use.
newer := opportunity("ocds-1", "Newer", 2, "r2")
newer.Classifications = newer.Classifications[1:]
total = save(newer)
if total.AddedClassifications != nil ||
total.RemovedClassificationIDs != nil {
t.Errorf(
"code still in use: added %v, removed %v; want none",
total.AddedClassifications, total.RemovedClassificationIDs,
)
}
// ocds-2 drops it too: it goes out of use.
newer = opportunity("ocds-2", "Newer", 2, "r2")
newer.Classifications = newer.Classifications[1:]
total = save(newer)
if total.AddedClassifications != nil ||
len(total.RemovedClassificationIDs) != 1 ||
total.RemovedClassificationIDs[0] != removedID {
t.Errorf(
"code out of use: added %v, removed %v; want removed [%d]",
total.AddedClassifications, total.RemovedClassificationIDs, removedID,
)
}
}