Added CODE_CONVENTIONS.md and linters to enforce it, and applied it across the codebase

CODE_CONVENTIONS.md sets out the conventions for Go code in this
repository: naming, layout, comments, errors, logging, configuration and
tests. .golangci.json enables the linters that check the parts that can be
checked (each limited to the rules the conventions use); the rest is
applied by hand.

Applied across the codebase:
- Naming: full-word names in application code, initialisms in one case
  (BuyerID, PrintJSON, PrintYAML, ocdsGetBuyerID), no names shadowing
  built-ins (min/max are minGap/maxGap), mustRaw renamed rawJSON as it
  returns nil rather than panicking, and config.ConfigLevelToLogLevel
  renamed LevelToLogLevel.
- Layout: lines kept to 80 characters where possible, assignments taken
  out of if and for statements that don't fit, wrapped arguments all on
  one line or one per line, steps separated by blank lines, no else after
  a return, function and struct literals wrapped.
- Comments: doc comments on every exported name (including each section's
  Map, Flatten, TypeOf, ValueOf and SetValue), section banners, British
  spelling.
- Errors: a created error starts with its package name, a wrapping error
  adds only what was being done, sentinels in one commented var block,
  errors.Is and errors.As in place of == and type assertions, and no
  err := shadowing of a named result. That fixes the mock server losing
  its access log's close error.
- Logging: the fetcher passes its workers an untagged logger, so
  findatender's lines are tagged with their own component rather than
  with fetcher's as well; cmd/mock-fts logs "stopped" with slog.
- Configuration: util.WithTimeout (moved from the websocket package)
  applies a timeout of 0 or less as no limit, used for the websocket
  write and ping, TCP close and tcpserver shutdown timeouts; every setting
  documents what 0 or empty means, including the rate limit rule's keys
  in internal/config/README.md.
- Tests: related tests merged into table-driven ones with named cases.

Separately from CODE_CONVENTIONS.md, internal/store/README.md gains a soft
SQL style convention for the queries in internal/store/queries.go, and the
queries are reformatted to follow it.
This commit is contained in:
bwilliams committed 2026-10-15 16:26:42 +01:00
1 parent 465358609a
commit 67215a6aaa
104 files changed
+3189 -1206

No files matched your search

+187 -113
View File
@@ -18,8 +18,25 @@ import (
"git.avsdev.uk/Go/job-search/internal/types"
)
// ErrNotFound is returned when a requested opportunity does not exist.
var ErrNotFound = errors.New("store: not found")
// Errors returned by the store.
var (
// ErrNotFound is returned when a requested opportunity does not exist.
ErrNotFound = errors.New("store: not found")
// ErrNeedsMigration is returned by Open when the database has pending
// migrations. Apply them with the migrate command, or pass
// WithAutoMigrate.
ErrNeedsMigration = errors.New("store: database needs migrating")
// ErrSchemaTooNew is returned by Open when the database has migrations
// this build doesn't know about, i.e. it was migrated by a newer build.
ErrSchemaTooNew = errors.New(
"store: database schema is newer than this build",
)
// ErrWouldEmptyDatabase is returned by StepDown when the migration would
// drop all of the tables and thus empty the database.
ErrWouldEmptyDatabase = errors.New(
"store: migration cancelled; database would be emptied by undoing all migrations",
)
)
// DefaultListLimit is used when OpportunityFilter.Limit is zero or negative.
const DefaultListLimit = 50
@@ -32,14 +49,15 @@ type Store struct {
// SaveMultipleResult reports what SaveOpportunities changed.
type SaveMultipleResult struct {
// NewOpportunities is the number of opportunities that were not already stored.
// NewOpportunities is the number of opportunities that were not already
// stored.
NewOpportunities int
// NewReleases is the number of releases that were not already stored.
NewReleases int
// UpdatedOpportunities is incremented for each opportunity row and its classifications
// that are updated (when every release in the save was older than the
// one the stored opportunity was last updated from). It does not include new
// opportunities.
// UpdatedOpportunities is incremented for each opportunity row and its
// classifications that are updated (when every release in the save was older
// than the one the stored opportunity was last updated from). It does not
// include new opportunities.
UpdatedOpportunities int
// OpportunityResults is a slice of the results of each opportunity saved.
OpportunityResults []SaveResult
@@ -47,8 +65,8 @@ type SaveMultipleResult struct {
// SaveResult reports what SaveOpportunity changed.
type SaveResult struct {
// OpportunityUpdated is true if the opportunity row and its classifications were
// written. It is false when every release in the save was older than the
// OpportunityUpdated is true if the opportunity row and its classifications
// were written. It is false when every release in the save was older than the
// one the stored opportunity was last updated from.
OpportunityUpdated bool
// NewReleases is the number of releases that were not already stored.
@@ -58,6 +76,7 @@ type SaveResult struct {
// MarkFilter filters ListOpportunities on one of an opportunity's marks.
type MarkFilter int
// Values of MarkFilter.
const (
// MarkAny does not filter. It is the zero value.
MarkAny MarkFilter = iota
@@ -68,8 +87,8 @@ const (
MarkUnset
)
// OpportunityFilter selects opportunities for ListOpportunities. Zero-valued fields do not
// filter.
// OpportunityFilter selects opportunities for ListOpportunities. Zero-valued
// fields do not filter.
type OpportunityFilter struct {
Status string
// ClassificationCode matches opportunities with a classification of this code
@@ -117,14 +136,18 @@ func prepareOpportunity(opportunity *types.Opportunity) error {
opportunity.Marks.UUID = opportunity.UUID
if err := opportunity.Marks.Validate(); err != nil {
return fmt.Errorf("store: opportunity %s: %w", opportunity.UUID, err)
return fmt.Errorf("opportunity %s: %w", opportunity.UUID, err)
}
latest := opportunity.LatestReleaseDate
for i := range opportunity.Releases {
release := &opportunity.Releases[i]
if release.ReleaseID == "" {
return fmt.Errorf("store: opportunity %s: release %d has no release id", opportunity.UUID, i)
return fmt.Errorf(
"store: opportunity %s: release %d has no release id",
opportunity.UUID,
i,
)
}
release.UUID = opportunity.UUID
release.ReleaseDate = release.ReleaseDate.UTC()
@@ -138,7 +161,9 @@ func prepareOpportunity(opportunity *types.Opportunity) error {
}
if latest.IsZero() {
return fmt.Errorf("store: opportunity %s has no release date", opportunity.UUID)
return fmt.Errorf(
"store: opportunity %s has no release date", opportunity.UUID,
)
}
opportunity.LatestReleaseDate = latest.UTC()
@@ -188,12 +213,15 @@ func Open(ctx context.Context, path string, opts ...Option) (*Store, error) {
if err != nil {
return nil, err
}
if err := sqldb.PingContext(ctx); err != nil {
return nil, errors.Join(err, sqldb.Close())
}
if err := prepareSchema(ctx, sqldb, settings); err != nil {
return nil, errors.Join(err, sqldb.Close())
}
return &Store{
db: sqlx.NewDb(sqldb, "sqlite3"),
}, nil
@@ -207,10 +235,10 @@ func (s *Store) Close() error { return s.db.Close() }
// reads fail loudly ("missing destination name ...") until it is mapped,
// instead of being silently ignored.
//
// opportunityRow gives the opportunities table's internal id column a home, so it stays
// out of types.Opportunity. sqlx maps the embedded Opportunity's fields as if declared
// here. The other tables have no internal columns and read straight into
// internal/types.
// opportunityRow gives the opportunities table's internal id column a home, so
// it stays out of types.Opportunity. sqlx maps the embedded Opportunity's
// fields as if declared here. The other tables have no internal columns and
// read straight into internal/types.
type opportunityRow struct {
ID int64 `db:"id"`
types.Opportunity
@@ -230,8 +258,7 @@ type opportunityRow struct {
// opportunity is normalised in place (see prepareOpportunity), and when it is
// written its classifications' IDs are filled in.
func (s *Store) SaveOpportunity(
ctx context.Context,
opportunity *types.Opportunity,
ctx context.Context, opportunity *types.Opportunity,
) (SaveResult, error) {
if err := prepareOpportunity(opportunity); err != nil {
return SaveResult{}, err
@@ -243,14 +270,16 @@ func (s *Store) SaveOpportunity(
}
defer tx.Rollback() //nolint:errcheck // no-op after Commit
res, _, err := saveOpportunity(ctx, tx, opportunity)
result, _, err := saveOpportunity(ctx, tx, opportunity)
if err != nil {
return SaveResult{}, err
}
if err := tx.Commit(); err != nil {
return SaveResult{}, err
}
return res, nil
return result, nil
}
// SaveOpportunities stores each of opportunities and its releases, all in one
@@ -282,28 +311,29 @@ func (s *Store) SaveOpportunities(
}
defer tx.Rollback() //nolint:errcheck // no-op after Commit
allRes := SaveMultipleResult{
total := SaveMultipleResult{
OpportunityResults: make([]SaveResult, len(opportunities)),
}
for i, opportunity := range opportunities {
res, isNew, err := saveOpportunity(ctx, tx, opportunity)
result, isNew, err := saveOpportunity(ctx, tx, opportunity)
if err != nil {
return SaveMultipleResult{}, err
}
allRes.OpportunityResults[i] = res
allRes.NewReleases += res.NewReleases
total.OpportunityResults[i] = result
total.NewReleases += result.NewReleases
switch {
case isNew:
allRes.NewOpportunities++
case res.OpportunityUpdated:
allRes.UpdatedOpportunities++
total.NewOpportunities++
case result.OpportunityUpdated:
total.UpdatedOpportunities++
}
}
if err := tx.Commit(); err != nil {
return SaveMultipleResult{}, err
}
return allRes, nil
return total, nil
}
// saveOpportunity does the work of SaveOpportunity and SaveOpportunities for
@@ -311,28 +341,31 @@ func (s *Store) SaveOpportunities(
// not already stored.
func saveOpportunity(
ctx context.Context, tx *sqlx.Tx, opportunity *types.Opportunity,
) (res SaveResult, isNew bool, err error) {
var stored time.Time
err = tx.GetContext(ctx, &stored, selectOpportunityLatestReleaseDate, opportunity.UUID)
) (SaveResult, bool, error) {
var (
result SaveResult
isNew bool
stored time.Time
)
err := tx.GetContext(
ctx, &stored, selectOpportunityLatestReleaseDate, opportunity.UUID,
)
switch {
case errors.Is(err, sql.ErrNoRows):
isNew = true
res.OpportunityUpdated = true
result.OpportunityUpdated = true
case err != nil:
return SaveResult{}, false, fmt.Errorf(
"store: reading opportunity %s: %w",
opportunity.UUID,
err,
"reading opportunity %s: %w", opportunity.UUID, err,
)
default:
res.OpportunityUpdated = !opportunity.LatestReleaseDate.Before(stored)
result.OpportunityUpdated = !opportunity.LatestReleaseDate.Before(stored)
}
if res.OpportunityUpdated {
if result.OpportunityUpdated {
if err := writeOpportunity(ctx, tx, opportunity); err != nil {
return SaveResult{}, false, fmt.Errorf(
"store: writing opportunity %s: %w",
opportunity.UUID,
err,
"writing opportunity %s: %w", opportunity.UUID, err,
)
}
}
@@ -343,10 +376,11 @@ func saveOpportunity(
return SaveResult{}, false, err
}
if inserted {
res.NewReleases++
result.NewReleases++
}
}
return res, isNew, nil
return result, isNew, nil
}
// insertReleaseWithTags stores release and its tags unless it is already
@@ -357,77 +391,96 @@ func insertReleaseWithTags(
result, err := tx.NamedExecContext(ctx, insertRelease, release)
if err != nil {
return false, fmt.Errorf(
"store: inserting release %s/%s: %w",
"inserting release %s/%s: %w",
release.UUID,
release.ReleaseID,
err,
)
}
if affected, err := result.RowsAffected(); err != nil || affected == 0 {
return false, err // n == 0: already stored
return false, err // affected == 0: already stored
}
for _, tag := range release.Tags {
if _, err := tx.NamedExecContext(ctx, insertReleaseTag, tag); err != nil {
return false, fmt.Errorf(
"store: inserting tag for %s/%s: %w", release.UUID, release.ReleaseID, err,
"inserting tag for %s/%s: %w",
release.UUID,
release.ReleaseID,
err,
)
}
}
return true, nil
}
// writeOpportunity upserts opportunity's row, gives it a marks row if it has none
// (existing marks are kept), and replaces its classifications, filling in
// writeOpportunity upserts opportunity's row, gives it a marks row if it has
// none (existing marks are kept), and replaces its classifications, filling in
// their IDs.
func writeOpportunity(
ctx context.Context,
tx *sqlx.Tx,
opportunity *types.Opportunity,
ctx context.Context, tx *sqlx.Tx, opportunity *types.Opportunity,
) (err error) {
if _, err := tx.NamedExecContext(ctx, upsertOpportunity, opportunity); err != nil {
return err
}
if _, err := tx.NamedExecContext(ctx, insertOpportunityMarks, opportunity.Marks); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, deleteOpportunityClassifications, opportunity.UUID); err != nil {
_, err = tx.NamedExecContext(ctx, upsertOpportunity, opportunity)
if err != nil {
return err
}
statement, err := tx.PrepareNamedContext(ctx, upsertClassificationCodeReturningID)
_, err = tx.NamedExecContext(ctx, insertOpportunityMarks, opportunity.Marks)
if err != nil {
return err
}
_, err = tx.ExecContext(
ctx, deleteOpportunityClassifications, opportunity.UUID,
)
if err != nil {
return err
}
statement, err := tx.PrepareNamedContext(
ctx, upsertClassificationCodeReturningID,
)
if err != nil {
return err
}
defer func() {
err = errors.Join(err, statement.Close())
}()
for i := range opportunity.Classifications {
classification := &opportunity.Classifications[i]
if err := statement.GetContext(ctx, &classification.ID, classification); err != nil {
err = statement.GetContext(ctx, &classification.ID, classification)
if err != nil {
return err
}
if _, err := tx.ExecContext(
ctx,
insertOpportunityClassification,
opportunity.UUID,
classification.ID,
); err != nil {
_, err = tx.ExecContext(
ctx, insertOpportunityClassification, opportunity.UUID, classification.ID,
)
if err != nil {
return err
}
}
return nil
}
// SetMarks replaces the marks of the opportunity marks.UUID, or returns
// ErrNotFound. Marks that fail marks.Validate (e.g. types.ErrWonAndLost) are
// not stored.
func (s *Store) SetMarks(ctx context.Context, marks types.OpportunityMarks) error {
func (s *Store) SetMarks(
ctx context.Context, marks types.OpportunityMarks,
) error {
if marks.UUID == "" {
return errors.New("store: marks have no UUID")
}
if err := marks.Validate(); err != nil {
return fmt.Errorf("store: opportunity %s: %w", marks.UUID, err)
return fmt.Errorf("opportunity %s: %w", marks.UUID, err)
}
_, err := s.db.NamedExecContext(ctx, upsertOpportunityMarks, marks)
if isForeignKeyError(err) {
return ErrNotFound
@@ -439,12 +492,15 @@ func (s *Store) SetMarks(ctx context.Context, marks types.OpportunityMarks) erro
// breaks a foreign key, e.g. one referring to a missing opportunity.
func isForeignKeyError(err error) bool {
var sqliteErr sqlite3.Error
return errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintForeignKey
return errors.As(err, &sqliteErr) &&
sqliteErr.ExtendedCode == sqlite3.ErrConstraintForeignKey
}
// GetOpportunity returns the opportunity with its classifications, marks and
// all of its releases (with tags), or ErrNotFound.
func (s *Store) GetOpportunity(ctx context.Context, uuid string) (*types.Opportunity, error) {
func (s *Store) GetOpportunity(
ctx context.Context, uuid string,
) (*types.Opportunity, error) {
var row opportunityRow
err := s.db.GetContext(ctx, &row, selectOpportunityByUUID, uuid)
if errors.Is(err, sql.ErrNoRows) {
@@ -453,6 +509,7 @@ func (s *Store) GetOpportunity(ctx context.Context, uuid string) (*types.Opportu
if err != nil {
return nil, err
}
opportunities := []types.Opportunity{row.Opportunity}
if err := s.loadClassifications(ctx, opportunities); err != nil {
return nil, err
@@ -460,9 +517,11 @@ func (s *Store) GetOpportunity(ctx context.Context, uuid string) (*types.Opportu
if err := s.loadMarks(ctx, opportunities); err != nil {
return nil, err
}
if opportunities[0].Releases, err = s.ListReleases(ctx, uuid); err != nil {
return nil, err
}
return &opportunities[0], nil
}
@@ -470,29 +529,32 @@ func (s *Store) GetOpportunity(ctx context.Context, uuid string) (*types.Opportu
// classifications and marks but without releases (use ListReleases or
// GetOpportunity for those).
func (s *Store) ListOpportunities(
ctx context.Context,
filter OpportunityFilter,
ctx context.Context, filter OpportunityFilter,
) ([]types.Opportunity, error) {
query, args := listOpportunitiesQuery(filter)
var rows []opportunityRow
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, err
}
opportunities := make([]types.Opportunity, len(rows))
for i, row := range rows {
opportunities[i] = row.Opportunity
}
if err := s.loadClassifications(ctx, opportunities); err != nil {
return nil, err
}
if err := s.loadMarks(ctx, opportunities); err != nil {
return nil, err
}
return opportunities, nil
}
// listOpportunitiesQuery assembles the ListOpportunities query from the fragments in
// queries.go, with one argument per filter that is set (none for mark filters).
// listOpportunitiesQuery assembles the ListOpportunities query from the
// fragments in queries.go, with one argument per filter that is set (none for
// mark filters).
func listOpportunitiesQuery(filter OpportunityFilter) (string, []any) {
var where []string
var args []any
@@ -500,6 +562,7 @@ func listOpportunitiesQuery(filter OpportunityFilter) (string, []any) {
where = append(where, clause)
args = append(args, argument)
}
if filter.Status != "" {
add(filterOpportunityStatus, filter.Status)
}
@@ -515,35 +578,19 @@ func listOpportunitiesQuery(filter OpportunityFilter) (string, []any) {
if !filter.ReleasedBefore.IsZero() {
add(filterOpportunityReleasedBefore, filter.ReleasedBefore.UTC())
}
for _, mark := range []struct {
marks := []struct {
filter MarkFilter
clause string
}{
{
filter: filter.Hidden,
clause: filterOpportunityMarkHidden,
},
{
filter: filter.Skipped,
clause: filterOpportunityMarkSkipped,
},
{
filter: filter.Interested,
clause: filterOpportunityMarkInterested,
},
{
filter: filter.BidSubmitted,
clause: filterOpportunityMarkBidSubmitted,
},
{
filter: filter.Won,
clause: filterOpportunityMarkWon,
},
{
filter: filter.Lost,
clause: filterOpportunityMarkLost,
},
} {
{filter: filter.Hidden, clause: filterOpportunityMarkHidden},
{filter: filter.Skipped, clause: filterOpportunityMarkSkipped},
{filter: filter.Interested, clause: filterOpportunityMarkInterested},
{filter: filter.BidSubmitted, clause: filterOpportunityMarkBidSubmitted},
{filter: filter.Won, clause: filterOpportunityMarkWon},
{filter: filter.Lost, clause: filterOpportunityMarkLost},
}
for _, mark := range marks {
switch mark.filter {
case MarkSet:
where = append(where, mark.clause)
@@ -557,14 +604,19 @@ func listOpportunitiesQuery(filter OpportunityFilter) (string, []any) {
query += "\nWHERE " + strings.Join(where, "\n\tAND ")
}
query += listOpportunitiesOrderAndPage
return query, append(args, filter.limit(), filter.Offset)
}
// loadClassifications fills in Classifications for all opportunities with one query.
func (s *Store) loadClassifications(ctx context.Context, opportunities []types.Opportunity) error {
// loadClassifications fills in Classifications for all opportunities with one
// query.
func (s *Store) loadClassifications(
ctx context.Context, opportunities []types.Opportunity,
) error {
if len(opportunities) == 0 {
return nil
}
uuids := make([]string, len(opportunities))
byUUID := make(map[string]*types.Opportunity, len(opportunities))
for i := range opportunities {
@@ -576,27 +628,36 @@ func (s *Store) loadClassifications(ctx context.Context, opportunities []types.O
if err != nil {
return err
}
// opportunity_uuid says which opportunity each row belongs to.
var rows []struct {
OpportunityUUID string `db:"opportunity_uuid"`
types.ClassificationCode
}
if err := s.db.SelectContext(ctx, &rows, s.db.Rebind(query), args...); err != nil {
err = s.db.SelectContext(ctx, &rows, s.db.Rebind(query), args...)
if err != nil {
return err
}
for _, releaseRow := range rows {
opportunity := byUUID[releaseRow.OpportunityUUID]
opportunity.Classifications = append(opportunity.Classifications, releaseRow.ClassificationCode)
for _, classificationRow := range rows {
opportunity := byUUID[classificationRow.OpportunityUUID]
opportunity.Classifications = append(
opportunity.Classifications, classificationRow.ClassificationCode,
)
}
return nil
}
// loadMarks fills in Marks for all opportunities with one query. An
// opportunity with no marks row gets zero marks carrying its UUID.
func (s *Store) loadMarks(ctx context.Context, opportunities []types.Opportunity) error {
func (s *Store) loadMarks(
ctx context.Context, opportunities []types.Opportunity,
) error {
if len(opportunities) == 0 {
return nil
}
uuids := make([]string, len(opportunities))
byUUID := make(map[string]*types.Opportunity, len(opportunities))
for i := range opportunities {
@@ -611,42 +672,55 @@ func (s *Store) loadMarks(ctx context.Context, opportunities []types.Opportunity
if err != nil {
return err
}
var rows []types.OpportunityMarks
if err := s.db.SelectContext(ctx, &rows, s.db.Rebind(query), args...); err != nil {
err = s.db.SelectContext(ctx, &rows, s.db.Rebind(query), args...)
if err != nil {
return err
}
for _, row := range rows {
byUUID[row.UUID].Marks = row
}
return nil
}
// ListReleases returns a opportunity's releases with their tags, oldest first.
func (s *Store) ListReleases(ctx context.Context, uuid string) ([]types.OpportunityRelease, error) {
func (s *Store) ListReleases(
ctx context.Context, uuid string,
) ([]types.OpportunityRelease, error) {
releases := []types.OpportunityRelease{}
if err := s.db.SelectContext(ctx, &releases, selectReleasesByOpportunity, uuid); err != nil {
err := s.db.SelectContext(ctx, &releases, selectReleasesByOpportunity, uuid)
if err != nil {
return nil, err
}
byID := make(map[string]*types.OpportunityRelease, len(releases))
for i := range releases {
byID[releases[i].ReleaseID] = &releases[i]
}
var tags []types.OpportunityReleaseTag
if err := s.db.SelectContext(ctx, &tags, selectReleaseTagsByOpportunity, uuid); err != nil {
err = s.db.SelectContext(ctx, &tags, selectReleaseTagsByOpportunity, uuid)
if err != nil {
return nil, err
}
for _, tag := range tags {
if release := byID[tag.ReleaseID]; release != nil {
release.Tags = append(release.Tags, tag)
}
}
return releases, nil
}
// ReleaseExists reports whether a release is already stored, so a fetcher
// can skip converting it.
func (s *Store) ReleaseExists(ctx context.Context, uuid, releaseID string) (bool, error) {
func (s *Store) ReleaseExists(
ctx context.Context, uuid, releaseID string,
) (bool, error) {
var exists bool
err := s.db.GetContext(ctx, &exists, selectReleaseExists, uuid, releaseID)
return exists, err