Added change batches, so clients can catch up on changed opportunities
Every save that stores new opportunities or new releases records them in a change batch (migration 00008: opportunity_change_batches and opportunity_change_batch_opportunities; the opportunities table is unchanged), as does every change of marks. A batch is named by a random SHA-256 hash, so it can't be guessed, and ordered by its created_at, which is kept strictly increasing even if the clock goes back. GET /api/opportunities pages now carry the newest batch, read before the page, and GET /api/opportunities?changed_after=<batch> returns every opportunity changed since that batch, unpaged, with the newest batch it includes. Offset and limit can't be combined with it, and an unknown batch is 410 Gone, telling the client to reload the whole list. OpportunitiesUpdated now carries its save's batch, and a "large" flag: over 50 opportunities, new and updated are left empty and only the summary counts them, so a big catch-up no longer sends tens of thousands of UUIDs.
This commit is contained in:
1 parent
51d6470649
commit
155b84caea
13 files changed
+699
-29
No files matched your search
@@ -39,6 +39,8 @@ internal/store/common.go common for migrate, store and direct access
|
||||
| `classification_codes` | `id` | Shared between opportunities. A row is reused only when every content column matches. The table grows over time to include codes not already seen in fetched opportunities. |
|
||||
| `opportunity_classifications` | (`opportunity_uuid`, `classification_id`) | Mapping table; replaced as a set when the opportunity is updated. |
|
||||
| `opportunities_fts` | rowid = `opportunities.id` | FTS5 index on `opportunities.title`, kept in sync by triggers. |
|
||||
| `opportunity_change_batches` | `id`; `hash` (unique) | Write-once. One row per save that adds opportunities or gives them new releases, and per change of marks. `hash` names the batch to clients (random, so unguessable); `created_at` orders the batches. |
|
||||
| `opportunity_change_batch_opportunities` | (`batch_id`, `opportunity_uuid`) | The opportunities in each batch. References both tables, cascading on delete. |
|
||||
|
||||
Foreign keys are enforced (`_foreign_keys=on` in the connection string) and cascade on delete from `opportunities`. The connection also enables WAL mode, a 5s busy timeout, and `_txlock=immediate`, so a save takes the write lock up front rather than failing halfway through.
|
||||
|
||||
@@ -88,7 +90,7 @@ A soft convention for the SQL in `queries.go`, followed so the queries look alik
|
||||
- A column list that doesn't fit on one line goes inside its brackets, indented one tab and filled to about 80 characters per line. The `VALUES` list wraps at the same columns, so each line of values matches the line of columns above it.
|
||||
- `ON CONFLICT … DO UPDATE SET` puts one `column = excluded.column` per line, indented one tab.
|
||||
- A subquery (`EXISTS (…)`, `IN (…)`) is indented one tab inside its brackets, with the closing bracket at the start of a line.
|
||||
- Tables are aliased consistently: `t` for `opportunities`, `c` for `classification_codes`, `tc` for `opportunity_classifications`, `m` for `opportunity_marks`.
|
||||
- Tables are aliased consistently: `t` for `opportunities`, `c` for `classification_codes`, `tc` for `opportunity_classifications`, `m` for `opportunity_marks`, `b` for `opportunity_change_batches`, `bo` for `opportunity_change_batch_opportunities`.
|
||||
|
||||
## Store API
|
||||
|
||||
@@ -106,6 +108,8 @@ ts, err := s.ListOpportunities(ctx, store.OpportunityFilter{
|
||||
Limit: 20,
|
||||
})
|
||||
rs, err := s.ListReleases(ctx, uuid) // oldest first, with tags
|
||||
batch, err := s.LatestChangeBatch(ctx) // hash of the newest change batch, or ""
|
||||
ts, batch, err = s.ListChangedOpportunities(ctx, batch) // changed since; ErrUnknownBatch
|
||||
ok, err := s.ReleaseExists(ctx, uuid, releaseID)
|
||||
```
|
||||
|
||||
@@ -122,5 +126,8 @@ ok, err := s.ReleaseExists(ctx, uuid, releaseID)
|
||||
**Listing.** `ListOpportunities` filters by status, classification code, title text, a range on `LatestReleaseDate` and each mark (`MarkSet` or `MarkUnset`; an opportunity with no marks stored has every mark unset), newest first, 50 per page by default. It loads classifications and marks but not releases (which can be large); those are loaded by `ListReleases` or `GetOpportunity`.
|
||||
|
||||
|
||||
**Changes.** Each save that adds opportunities or gives them new releases records them in a change batch, in the same transaction, and `SaveOpportunities` reports the batch's hash (`ChangeBatch`). `SetMarks` records the opportunity in a batch of its own. A client that read the list after batch B (`LatestChangeBatch`, read before listing) gets everything changed since from `ListChangedOpportunities(ctx, B)`, with the newest batch to ask from next time; an unknown hash is `ErrUnknownBatch`. Batches are ordered by `created_at`, which `recordChangeBatch` keeps strictly increasing even if the clock goes back, and only batches up to the newest when the call starts are read, so one recorded meanwhile is left for next time rather than missed. Batches are never deleted yet.
|
||||
|
||||
|
||||
**Title search.** The words are matched against the title using FTS5: every word must appear, each also matches as a prefix, case and accents are ignored, and "migrating" finds "migration", etc. Input is sanitised, so FTS5 operators and quotes typed by a user are searched as plain words, never interpreted.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/types"
|
||||
)
|
||||
|
||||
// A batch recorded after the clock went back is still created after the
|
||||
// newest batch, so asking for the batches after that one finds it.
|
||||
func TestChangeBatchAfterClockWentBack(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
s := &Store{db: db}
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := s.SaveOpportunity(
|
||||
ctx,
|
||||
&types.Opportunity{
|
||||
UUID: "ocds-1",
|
||||
LatestReleaseDate: time.Now(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
future := time.Now().UTC().Add(time.Hour)
|
||||
db.MustExec(insertChangeBatch, "future", future)
|
||||
|
||||
err = s.SetMarks(ctx, types.OpportunityMarks{
|
||||
UUID: "ocds-1",
|
||||
Won: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var latest changeBatch
|
||||
if err := db.Get(&latest, selectLatestChangeBatch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !latest.CreatedAt.After(future) {
|
||||
t.Errorf("batch created at %v, not after %v", latest.CreatedAt, future)
|
||||
}
|
||||
|
||||
got, hash, err := s.ListChangedOpportunities(ctx, "future")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].UUID != "ocds-1" || hash != latest.Hash {
|
||||
t.Errorf("changed after future: %v, %q", got, hash)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
-- A batch of changed opportunities: those one save stored for the first time
|
||||
-- or gave new releases, or the one whose marks were changed. A client asks
|
||||
-- for what changed after a batch it has seen by the batch's hash.
|
||||
CREATE TABLE IF NOT EXISTS "opportunity_change_batches" (
|
||||
"id" INTEGER PRIMARY KEY,
|
||||
-- A random hash naming the batch to clients, so a batch can't be guessed
|
||||
-- or worked out from another.
|
||||
"hash" TEXT NOT NULL UNIQUE,
|
||||
-- When the batch was recorded, in UTC. Each batch's is later than every
|
||||
-- earlier batch's, so the batches after one are those created after it.
|
||||
"created_at" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "opportunity_change_batches_created_at"
|
||||
ON "opportunity_change_batches" ("created_at");
|
||||
|
||||
-- The opportunities in each batch.
|
||||
CREATE TABLE IF NOT EXISTS "opportunity_change_batch_opportunities" (
|
||||
"batch_id" INTEGER NOT NULL
|
||||
REFERENCES "opportunity_change_batches" ("id") ON DELETE CASCADE,
|
||||
"opportunity_uuid" TEXT NOT NULL
|
||||
REFERENCES "opportunities" ("uuid") ON DELETE CASCADE,
|
||||
PRIMARY KEY ("batch_id", "opportunity_uuid")
|
||||
) WITHOUT ROWID;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS "opportunity_change_batch_opportunities";
|
||||
DROP TABLE IF EXISTS "opportunity_change_batches";
|
||||
-- +goose StatementEnd
|
||||
@@ -277,3 +277,45 @@ SELECT *
|
||||
FROM opportunity_marks
|
||||
WHERE uuid IN (?)
|
||||
ORDER BY uuid`
|
||||
|
||||
// ---------- opportunity_change_batches ----------
|
||||
|
||||
// insertChangeBatch records a batch, returning its id. The created_at
|
||||
// argument must be later than every stored batch's (see recordChangeBatch).
|
||||
const insertChangeBatch = `
|
||||
INSERT INTO opportunity_change_batches (hash, created_at)
|
||||
VALUES (?, ?)
|
||||
RETURNING id`
|
||||
|
||||
// selectLatestChangeBatch reads the newest batch into a changeBatch.
|
||||
const selectLatestChangeBatch = `
|
||||
SELECT *
|
||||
FROM opportunity_change_batches
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`
|
||||
|
||||
// selectChangeBatchByHash reads the batch with a hash into a changeBatch.
|
||||
const selectChangeBatchByHash = `
|
||||
SELECT * FROM opportunity_change_batches WHERE hash = ?`
|
||||
|
||||
// ---------- opportunity_change_batch_opportunities ----------
|
||||
|
||||
// insertChangeBatchOpportunity adds an opportunity to a batch.
|
||||
const insertChangeBatchOpportunity = `
|
||||
INSERT INTO opportunity_change_batch_opportunities (batch_id, opportunity_uuid)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT DO NOTHING`
|
||||
|
||||
// selectOpportunitiesChangedBetween reads, into opportunityRows, the
|
||||
// opportunities in the batches created after the first argument and at or
|
||||
// before the second, once each, ordered as ListOpportunities orders them.
|
||||
const selectOpportunitiesChangedBetween = `
|
||||
SELECT t.*
|
||||
FROM opportunities t
|
||||
WHERE t.uuid IN (
|
||||
SELECT bo.opportunity_uuid
|
||||
FROM opportunity_change_batch_opportunities bo
|
||||
JOIN opportunity_change_batches b ON b.id = bo.batch_id
|
||||
WHERE b.created_at > ? AND b.created_at <= ?
|
||||
)
|
||||
ORDER BY t.latest_release_date DESC, t.uuid`
|
||||
@@ -112,6 +112,17 @@ var checks = map[string]queryCheck{
|
||||
"insertOpportunityMarks": {sql: insertOpportunityMarks, named: true},
|
||||
"upsertOpportunityMarks": {sql: upsertOpportunityMarks, named: true},
|
||||
"selectMarksForOpportunities": {sql: selectMarksForOpportunities, in: true},
|
||||
|
||||
// opportunity_change_batches
|
||||
"insertChangeBatch": {sql: insertChangeBatch},
|
||||
"selectLatestChangeBatch": {sql: selectLatestChangeBatch},
|
||||
"selectChangeBatchByHash": {sql: selectChangeBatchByHash},
|
||||
|
||||
// opportunity_change_batch_opportunities
|
||||
"insertChangeBatchOpportunity": {sql: insertChangeBatchOpportunity},
|
||||
"selectOpportunitiesChangedBetween": {
|
||||
sql: selectOpportunitiesChangedBetween,
|
||||
},
|
||||
}
|
||||
|
||||
func withFilter(fragment string) string {
|
||||
|
||||
+206
-4
@@ -5,7 +5,10 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -22,6 +25,9 @@ import (
|
||||
var (
|
||||
// ErrNotFound is returned when a requested opportunity does not exist.
|
||||
ErrNotFound = errors.New("store: not found")
|
||||
// ErrUnknownBatch is returned by ListChangedOpportunities for a hash
|
||||
// that names no change batch.
|
||||
ErrUnknownBatch = errors.New("store: unknown change batch")
|
||||
// ErrNeedsMigration is returned by Open when the database has pending
|
||||
// migrations. Apply them with the migrate command, or pass
|
||||
// WithAutoMigrate.
|
||||
@@ -69,6 +75,10 @@ type SaveMultipleResult struct {
|
||||
// a stored opportunity had before the save and none has after it, in
|
||||
// code order; nil if there are none.
|
||||
RemovedClassificationIDs []int64
|
||||
// ChangeBatch is the hash of the change batch recording the
|
||||
// opportunities the save stored for the first time or gave new releases
|
||||
// (see ListChangedOpportunities); "" if there are none.
|
||||
ChangeBatch string
|
||||
}
|
||||
|
||||
// SaveResult reports what SaveOpportunity changed.
|
||||
@@ -254,6 +264,15 @@ type opportunityRow struct {
|
||||
types.Opportunity
|
||||
}
|
||||
|
||||
// changeBatch is a row of opportunity_change_batches: a batch of changed
|
||||
// opportunities, named to clients by its Hash. ID and CreatedAt are internal
|
||||
// to the store.
|
||||
type changeBatch struct {
|
||||
ID int64 `db:"id"`
|
||||
Hash string `db:"hash"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
// SaveOpportunity stores opportunity and its releases in one transaction.
|
||||
//
|
||||
// Releases (and their tags) are write-once: ones already stored are skipped.
|
||||
@@ -265,6 +284,9 @@ type opportunityRow struct {
|
||||
// opportunity.Marks are stored only for a new opportunity; existing marks are
|
||||
// kept (use SetMarks to change them).
|
||||
//
|
||||
// A new opportunity, or one given new releases, is recorded in a change
|
||||
// batch of its own (see ListChangedOpportunities).
|
||||
//
|
||||
// opportunity is normalised in place (see prepareOpportunity), and when it is
|
||||
// written its classifications' IDs are filled in.
|
||||
func (s *Store) SaveOpportunity(
|
||||
@@ -285,6 +307,15 @@ func (s *Store) SaveOpportunity(
|
||||
return SaveResult{}, err
|
||||
}
|
||||
|
||||
_, err = recordChangeBatch(
|
||||
ctx,
|
||||
tx,
|
||||
changedUUIDs([]*types.Opportunity{opportunity}, []SaveResult{result}),
|
||||
)
|
||||
if err != nil {
|
||||
return SaveResult{}, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return SaveResult{}, err
|
||||
}
|
||||
@@ -307,7 +338,9 @@ func (s *Store) SaveOpportunity(
|
||||
// Each opportunity is normalised in place (see prepareOpportunity), and when
|
||||
// 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).
|
||||
// ListClassifications), and the hash of the change batch recording the
|
||||
// opportunities that were new or given new releases (see
|
||||
// ListChangedOpportunities).
|
||||
func (s *Store) SaveOpportunities(
|
||||
ctx context.Context, opportunities []*types.Opportunity,
|
||||
) (SaveMultipleResult, error) {
|
||||
@@ -354,6 +387,13 @@ func (s *Store) SaveOpportunities(
|
||||
total.AddedClassifications = added
|
||||
total.RemovedClassificationIDs = removed
|
||||
|
||||
total.ChangeBatch, err = recordChangeBatch(
|
||||
ctx, tx, changedUUIDs(opportunities, total.OpportunityResults),
|
||||
)
|
||||
if err != nil {
|
||||
return SaveMultipleResult{}, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return SaveMultipleResult{}, err
|
||||
}
|
||||
@@ -406,6 +446,82 @@ func classificationChanges(
|
||||
return added, removed
|
||||
}
|
||||
|
||||
// changedUUIDs returns the UUIDs of the opportunities that results, from
|
||||
// saving them, show were new or given new releases. An opportunity whose row
|
||||
// was only rewritten, such as one fetched again from its newest stored
|
||||
// release, hasn't changed.
|
||||
func changedUUIDs(
|
||||
opportunities []*types.Opportunity, results []SaveResult,
|
||||
) []string {
|
||||
var uuids []string
|
||||
for i, result := range results {
|
||||
if result.NewOpportunity || result.NewReleases > 0 {
|
||||
uuids = append(uuids, opportunities[i].UUID)
|
||||
}
|
||||
}
|
||||
|
||||
return uuids
|
||||
}
|
||||
|
||||
// recordChangeBatch records a change batch of the opportunities uuids inside
|
||||
// tx and returns its hash, or "" without recording anything if uuids is
|
||||
// empty.
|
||||
//
|
||||
// The batch's created_at is the current time, or just after the newest
|
||||
// batch's if that isn't later (the clock went back, or two batches came
|
||||
// within its resolution), so batches are ordered by created_at. tx holds the
|
||||
// write lock from its start (see dsn), so no other batch can be recorded in
|
||||
// between.
|
||||
func recordChangeBatch(
|
||||
ctx context.Context, tx *sqlx.Tx, uuids []string,
|
||||
) (string, error) {
|
||||
if len(uuids) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var latest changeBatch
|
||||
err := tx.GetContext(ctx, &latest, selectLatestChangeBatch)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("reading the latest change batch: %w", err)
|
||||
}
|
||||
|
||||
createdAt := time.Now().UTC()
|
||||
if !createdAt.After(latest.CreatedAt) {
|
||||
createdAt = latest.CreatedAt.Add(time.Nanosecond)
|
||||
}
|
||||
hash := newBatchHash(createdAt)
|
||||
|
||||
var batchID int64
|
||||
err = tx.GetContext(ctx, &batchID, insertChangeBatch, hash, createdAt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("recording a change batch: %w", err)
|
||||
}
|
||||
|
||||
for _, uuid := range uuids {
|
||||
_, err = tx.ExecContext(ctx, insertChangeBatchOpportunity, batchID, uuid)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"recording %s in a change batch: %w", uuid, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
// newBatchHash returns the hash of a change batch created at createdAt: the
|
||||
// hex SHA-256 hash of random bytes and the time. The random bytes make it
|
||||
// unguessable, so a client can only name a batch it was given.
|
||||
func newBatchHash(createdAt time.Time) string {
|
||||
random := make([]byte, 32)
|
||||
rand.Read(random) // crypto/rand.Read never returns an error
|
||||
|
||||
hash := sha256.Sum256(
|
||||
append(random, createdAt.Format(time.RFC3339Nano)...),
|
||||
)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// saveOpportunity does the work of SaveOpportunity and SaveOpportunities for
|
||||
// one prepared opportunity inside tx. isNew reports that the opportunity was
|
||||
// not already stored.
|
||||
@@ -539,7 +655,8 @@ func writeOpportunity(
|
||||
|
||||
// SetMarks replaces the marks of the opportunity marks.UUID, or returns
|
||||
// ErrNotFound. Marks that fail marks.Validate (e.g. types.ErrWonAndLost) are
|
||||
// not stored.
|
||||
// not stored. The opportunity is recorded in a change batch of its own (see
|
||||
// ListChangedOpportunities), so a client catching up sees its new marks.
|
||||
func (s *Store) SetMarks(
|
||||
ctx context.Context, marks types.OpportunityMarks,
|
||||
) error {
|
||||
@@ -550,11 +667,26 @@ func (s *Store) SetMarks(
|
||||
return fmt.Errorf("opportunity %s: %w", marks.UUID, err)
|
||||
}
|
||||
|
||||
_, err := s.db.NamedExecContext(ctx, upsertOpportunityMarks, marks)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op after Commit
|
||||
|
||||
_, err = tx.NamedExecContext(ctx, upsertOpportunityMarks, marks)
|
||||
if isForeignKeyError(err) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = recordChangeBatch(ctx, tx, []string{marks.UUID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// isForeignKeyError reports whether err is SQLite rejecting a write that
|
||||
@@ -606,6 +738,76 @@ func (s *Store) ListOpportunities(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.opportunitiesFromRows(ctx, rows)
|
||||
}
|
||||
|
||||
// LatestChangeBatch returns the hash of the newest change batch, or "" if
|
||||
// none has been recorded. Read before listing opportunities, it is the batch
|
||||
// to give ListChangedOpportunities later to get what changed since.
|
||||
func (s *Store) LatestChangeBatch(ctx context.Context) (string, error) {
|
||||
var latest changeBatch
|
||||
err := s.db.GetContext(ctx, &latest, selectLatestChangeBatch)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading the latest change batch: %w", err)
|
||||
}
|
||||
|
||||
return latest.Hash, nil
|
||||
}
|
||||
|
||||
// ListChangedOpportunities returns the opportunities recorded in the change
|
||||
// batches created after the batch with the hash after, once each, as
|
||||
// ListOpportunities returns them but unpaged, and the hash of the newest of
|
||||
// those batches, to give it next time. With no newer batches, it returns no
|
||||
// opportunities and after. An unknown hash is ErrUnknownBatch.
|
||||
//
|
||||
// Batches recorded while it runs are left for next time, so none is missed:
|
||||
// only those up to the newest batch when it starts are read.
|
||||
func (s *Store) ListChangedOpportunities(
|
||||
ctx context.Context, after string,
|
||||
) ([]types.Opportunity, string, error) {
|
||||
var since changeBatch
|
||||
err := s.db.GetContext(ctx, &since, selectChangeBatchByHash, after)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, "", ErrUnknownBatch
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("reading change batch %s: %w", after, err)
|
||||
}
|
||||
|
||||
var latest changeBatch
|
||||
err = s.db.GetContext(ctx, &latest, selectLatestChangeBatch)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("reading the latest change batch: %w", err)
|
||||
}
|
||||
|
||||
var rows []opportunityRow
|
||||
err = s.db.SelectContext(
|
||||
ctx,
|
||||
&rows,
|
||||
selectOpportunitiesChangedBetween,
|
||||
since.CreatedAt,
|
||||
latest.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("listing changed opportunities: %w", err)
|
||||
}
|
||||
|
||||
opportunities, err := s.opportunitiesFromRows(ctx, rows)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return opportunities, latest.Hash, nil
|
||||
}
|
||||
|
||||
// opportunitiesFromRows returns the opportunities in rows, in order, with
|
||||
// their classifications and marks. The result is never nil.
|
||||
func (s *Store) opportunitiesFromRows(
|
||||
ctx context.Context, rows []opportunityRow,
|
||||
) ([]types.Opportunity, error) {
|
||||
opportunities := make([]types.Opportunity, len(rows))
|
||||
for i, row := range rows {
|
||||
opportunities[i] = row.Opportunity
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -121,15 +122,7 @@ func uuids(ts []types.Opportunity) []string {
|
||||
}
|
||||
|
||||
func equal(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
return slices.Equal(a, b)
|
||||
}
|
||||
|
||||
func TestSaveAndGetRoundTrip(t *testing.T) {
|
||||
@@ -1010,3 +1003,96 @@ func TestSaveOpportunitiesClassificationChanges(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A save records the opportunities it adds or gives new releases in a change
|
||||
// batch, as does a change of marks, and ListChangedOpportunities returns
|
||||
// those in the batches after a given one.
|
||||
func TestChangeBatches(t *testing.T) {
|
||||
s := openStore(t)
|
||||
ctx := context.Background()
|
||||
save := func(opportunities ...*types.Opportunity) string {
|
||||
t.Helper()
|
||||
total, err := s.SaveOpportunities(ctx, opportunities)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return total.ChangeBatch
|
||||
}
|
||||
changed := func(after string) ([]types.Opportunity, string) {
|
||||
t.Helper()
|
||||
opportunities, latest, err := s.ListChangedOpportunities(ctx, after)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return opportunities, latest
|
||||
}
|
||||
latest := func() string {
|
||||
t.Helper()
|
||||
hash, err := s.LatestChangeBatch(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
if hash := latest(); hash != "" {
|
||||
t.Fatalf("empty store: latest batch %q, want none", hash)
|
||||
}
|
||||
|
||||
first := save(
|
||||
opportunity("ocds-1", "One", 1, "r1"),
|
||||
opportunity("ocds-2", "Two", 1, "r1"),
|
||||
)
|
||||
if len(first) != 64 || strings.Trim(first, "0123456789abcdef") != "" {
|
||||
t.Fatalf("batch %q is not a hex SHA-256 hash", first)
|
||||
}
|
||||
if hash := latest(); hash != first {
|
||||
t.Errorf("latest batch %q, want %q", hash, first)
|
||||
}
|
||||
|
||||
// Nothing new: no batch.
|
||||
if hash := save(opportunity("ocds-1", "One", 1, "r1")); hash != "" {
|
||||
t.Errorf("save with nothing new recorded batch %q", hash)
|
||||
}
|
||||
|
||||
second := save(
|
||||
opportunity("ocds-2", "Two", 2, "r1", "r2"),
|
||||
opportunity("ocds-3", "Three", 3, "r1"),
|
||||
)
|
||||
if second == "" || second == first {
|
||||
t.Fatalf("second batch %q, first %q", second, first)
|
||||
}
|
||||
|
||||
got, hash := changed(first)
|
||||
if want := []string{"ocds-3", "ocds-2"}; !equal(uuids(got), want) {
|
||||
t.Errorf("changed after first: %v, want %v", uuids(got), want)
|
||||
}
|
||||
if hash != second {
|
||||
t.Errorf("changed after first: latest %q, want %q", hash, second)
|
||||
}
|
||||
if len(got) == 2 && len(got[1].Classifications) != 2 {
|
||||
t.Errorf("changed opportunity without classifications: %+v", got[1])
|
||||
}
|
||||
|
||||
mustSetMarks(t, s, types.OpportunityMarks{
|
||||
UUID: "ocds-1",
|
||||
Skipped: true,
|
||||
})
|
||||
got, hash = changed(second)
|
||||
if !equal(uuids(got), []string{"ocds-1"}) || !got[0].Marks.Skipped {
|
||||
t.Errorf("changed after marks: %+v, want ocds-1 skipped", got)
|
||||
}
|
||||
if hash == second || hash != latest() {
|
||||
t.Errorf("changed after marks: latest %q, want a newer batch", hash)
|
||||
}
|
||||
|
||||
got, again := changed(hash)
|
||||
if got == nil || len(got) != 0 || again != hash {
|
||||
t.Errorf("changed after latest: %v, %q; want [], %q", got, again, hash)
|
||||
}
|
||||
|
||||
_, _, err := s.ListChangedOpportunities(ctx, "unknown")
|
||||
if !errors.Is(err, store.ErrUnknownBatch) {
|
||||
t.Errorf("unknown batch: err %v, want ErrUnknownBatch", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user