First pass implementation of an SQLite backing store for fetched opportunities
This commit is contained in:
1 parent
9b0e6dccfe
commit
045492a148
29 files changed
+3132
-9
No files matched your search
@@ -0,0 +1,211 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// Run with: go test -tags sqlite_fts5 ./...
|
||||
|
||||
// queryCheck is one statement to prepare. Preparing it makes SQLite parse it
|
||||
// against the migrated schema, catching syntax errors and unknown tables or
|
||||
// columns without running it. (Whether result columns map onto struct
|
||||
// fields is covered by the behavioural tests in store_test.go.)
|
||||
type queryCheck struct {
|
||||
sql string
|
||||
named bool // ":column" parameters, prepared via sqlx's named support
|
||||
in bool // contains "IN (?)" for sqlx.In
|
||||
}
|
||||
|
||||
// checks lists every constant in queries.go by name. Fragments are checked
|
||||
// inside a complete statement. TestQueriesAllListed fails if a constant is
|
||||
// missing here.
|
||||
var checks = map[string]queryCheck{
|
||||
// opportunities
|
||||
"selectOpportunityLatestReleaseDate": {sql: selectOpportunityLatestReleaseDate},
|
||||
"upsertOpportunity": {sql: upsertOpportunity, named: true},
|
||||
"selectOpportunityByUUID": {sql: selectOpportunityByUUID},
|
||||
"listOpportunitiesSelect": {
|
||||
sql: listOpportunitiesSelect + listOpportunitiesOrderAndPage,
|
||||
},
|
||||
"listOpportunitiesOrderAndPage": {
|
||||
sql: listOpportunitiesSelect + listOpportunitiesOrderAndPage,
|
||||
},
|
||||
"filterOpportunityStatus": {sql: withFilter(filterOpportunityStatus)},
|
||||
"filterOpportunityClassificationCode": {sql: withFilter(filterOpportunityClassificationCode)},
|
||||
"filterOpportunityTitleMatch": {sql: withFilter(filterOpportunityTitleMatch)},
|
||||
"filterOpportunityReleasedFrom": {sql: withFilter(filterOpportunityReleasedFrom)},
|
||||
"filterOpportunityReleasedBefore": {sql: withFilter(filterOpportunityReleasedBefore)},
|
||||
"filterOpportunityMarkHidden": {sql: withFilter(filterOpportunityMarkHidden)},
|
||||
"filterOpportunityMarkSkipped": {sql: withFilter(filterOpportunityMarkSkipped)},
|
||||
"filterOpportunityMarkInterested": {sql: withFilter(filterOpportunityMarkInterested)},
|
||||
"filterOpportunityMarkBidSubmitted": {sql: withFilter(filterOpportunityMarkBidSubmitted)},
|
||||
"filterOpportunityMarkWon": {sql: withFilter(filterOpportunityMarkWon)},
|
||||
"filterOpportunityMarkLost": {sql: withFilter(filterOpportunityMarkLost)},
|
||||
|
||||
// opportunity_ocds_releases
|
||||
"insertRelease": {sql: insertRelease, named: true},
|
||||
"selectReleasesByOpportunity": {sql: selectReleasesByOpportunity},
|
||||
"selectReleaseExists": {sql: selectReleaseExists},
|
||||
|
||||
// release_tags
|
||||
"insertReleaseTag": {sql: insertReleaseTag, named: true},
|
||||
"selectReleaseTagsByOpportunity": {sql: selectReleaseTagsByOpportunity},
|
||||
|
||||
// classification_codes
|
||||
"upsertClassificationCodeReturningID": {sql: upsertClassificationCodeReturningID, named: true},
|
||||
|
||||
// opportunity_classifications
|
||||
"deleteOpportunityClassifications": {sql: deleteOpportunityClassifications},
|
||||
"insertOpportunityClassification": {sql: insertOpportunityClassification},
|
||||
"selectClassificationsForOpportunities": {sql: selectClassificationsForOpportunities, in: true},
|
||||
|
||||
// opportunity_marks
|
||||
"insertOpportunityMarks": {sql: insertOpportunityMarks, named: true},
|
||||
"upsertOpportunityMarks": {sql: upsertOpportunityMarks, named: true},
|
||||
"selectMarksForOpportunities": {sql: selectMarksForOpportunities, in: true},
|
||||
}
|
||||
|
||||
func withFilter(fragment string) string {
|
||||
return listOpportunitiesSelect + "\nWHERE " + fragment + listOpportunitiesOrderAndPage
|
||||
}
|
||||
|
||||
func openTestDB(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
s, err := Open(context.Background(), filepath.Join(t.TempDir(), "queries.db"), WithAutoMigrate())
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s.db
|
||||
}
|
||||
|
||||
func prepare(db *sqlx.DB, c queryCheck) error {
|
||||
var err error
|
||||
switch {
|
||||
case c.named:
|
||||
var stmt *sqlx.NamedStmt
|
||||
if stmt, err = db.PrepareNamed(c.sql); err == nil {
|
||||
stmt.Close()
|
||||
}
|
||||
default:
|
||||
query := c.sql
|
||||
if c.in {
|
||||
var expanded string
|
||||
if expanded, _, err = sqlx.In(query, []string{"a", "b"}); err != nil {
|
||||
return err
|
||||
}
|
||||
query = db.Rebind(expanded)
|
||||
}
|
||||
var stmt *sqlx.Stmt
|
||||
if stmt, err = db.Preparex(query); err == nil {
|
||||
stmt.Close()
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestQueriesPrepare(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
for name, c := range checks {
|
||||
if err := prepare(db, c); err != nil {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOpportunitiesQueryAllFilters prepares the assembled query with every
|
||||
// filter set, checking the fragments combine correctly and that there is
|
||||
// exactly one argument per placeholder.
|
||||
func TestListOpportunitiesQueryAllFilters(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
query, args := listOpportunitiesQuery(OpportunityFilter{
|
||||
Status: "active",
|
||||
ClassificationCode: "72000000",
|
||||
Title: "cloud migration",
|
||||
ReleasedFrom: time.Now().Add(-time.Hour),
|
||||
ReleasedBefore: time.Now(),
|
||||
Hidden: MarkUnset,
|
||||
Skipped: MarkUnset,
|
||||
Interested: MarkSet,
|
||||
BidSubmitted: MarkSet,
|
||||
Won: MarkUnset,
|
||||
Lost: MarkUnset,
|
||||
Limit: 10,
|
||||
Offset: 5,
|
||||
})
|
||||
stmt, err := db.Preparex(query)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare:\n%s\n%v", query, err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
var rows []opportunityRow
|
||||
if err := stmt.Select(&rows, args...); err != nil {
|
||||
t.Errorf("run with %d args:\n%s\n%v", len(args), query, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueriesAllListed fails if queries.go declares a constant that checks
|
||||
// doesn't list, so every new statement is prepared by TestQueriesPrepare.
|
||||
func TestQueriesAllListed(t *testing.T) {
|
||||
file, err := parser.ParseFile(token.NewFileSet(), "queries.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var declared []string
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.CONST {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gen.Specs {
|
||||
for _, name := range spec.(*ast.ValueSpec).Names {
|
||||
declared = append(declared, name.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(declared)
|
||||
if len(declared) == 0 {
|
||||
t.Fatal("found no constants in queries.go")
|
||||
}
|
||||
for _, name := range declared {
|
||||
if _, ok := checks[name]; !ok {
|
||||
t.Errorf("queries.go constant %s is not listed in checks", name)
|
||||
}
|
||||
}
|
||||
for name := range checks {
|
||||
if i := sort.SearchStrings(declared, name); i == len(declared) || declared[i] != name {
|
||||
t.Errorf("checks lists %s, which queries.go does not declare", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarksCheckConstraint checks the database itself rejects won and lost
|
||||
// together, for writes that bypass OpportunityMarks.Validate.
|
||||
func TestMarksCheckConstraint(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if _, err := db.Exec(`INSERT INTO opportunities
|
||||
(uuid, uuid_type, title, status, latest_release_date)
|
||||
VALUES ('ocds-1', 'ocid', 'T', 'active', '2026-01-01T00:00:00Z')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO opportunity_marks (uuid, won, lost)
|
||||
VALUES ('ocds-1', 1, 1)`); err == nil {
|
||||
t.Error("inserting won and lost together succeeded")
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO opportunity_marks (uuid, won)
|
||||
VALUES ('ocds-1', 1)`); err != nil {
|
||||
t.Fatalf("inserting won: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE opportunity_marks SET lost = 1 WHERE uuid = 'ocds-1'`); err == nil {
|
||||
t.Error("updating to won and lost together succeeded")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user