Replaced the Title filter with a general search, run by the backend

GET /api/opportunities/search?q= returns the UUIDs of the matching
opportunities, and the frontend filters its list by them. Words without
a prompt search the title and description; title:, buyer:, buyer_id:
and cpv: search only their own field, and a leading ! leaves out what a
term matches. Repeats of one prompt are OR, everything else AND. CPV
terms are checked per code: cpv:72 !cpv:7221 needs a code starting 72
that doesn't start 7221.

Unknown prompts are reported, and every response carries the help that
the search box's "?" panel shows. Migration 00011 adds the description
and buyer name to the full-text index, and indexes CPV descriptions.
This commit is contained in:
bwilliams committed 2026-10-22 13:10:25 +01:00
1 parent 0a777792d8
commit b0230e6847
29 files changed
+1913 -43

No files matched your search

+47
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"fmt"
"go/ast"
"go/parser"
"go/token"
@@ -11,6 +12,8 @@ import (
"time"
"github.com/jmoiron/sqlx"
"git.avsdev.uk/Go/job-search/internal/search"
)
// Run with: go test -tags sqlite_fts5 ./...
@@ -81,6 +84,24 @@ var checks = map[string]queryCheck{
sql: withFilter(filterOpportunityMarkLost),
},
"searchOpportunitiesSelect": {sql: searchOpportunitiesSelect},
"searchOpportunityText": {
sql: withSearch(searchOpportunityText),
},
"searchOpportunityBuyerID": {
sql: withSearch(searchOpportunityBuyerID),
in: true,
},
"searchOpportunityCPV": {
sql: withSearch(fmt.Sprintf(searchOpportunityCPV, "1")),
},
"searchCPVCodePrefix": {
sql: withSearch(fmt.Sprintf(searchOpportunityCPV, searchCPVCodePrefix)),
},
"searchCPVDescription": {
sql: withSearch(fmt.Sprintf(searchOpportunityCPV, searchCPVDescription)),
},
// opportunity_ocds_releases
"insertRelease": {sql: insertRelease, named: true},
"selectReleasesByOpportunity": {sql: selectReleasesByOpportunity},
@@ -140,6 +161,10 @@ func withFilter(fragment string) string {
listOpportunitiesOrderAndPage
}
func withSearch(fragment string) string {
return searchOpportunitiesSelect + "\nWHERE " + fragment
}
func openTestDB(t *testing.T) *sqlx.DB {
t.Helper()
s, err := Open(
@@ -223,6 +248,28 @@ func TestListOpportunitiesQueryAllFilters(t *testing.T) {
}
}
// TestSearchOpportunitiesQueryAllTerms runs the assembled search query with
// a term of every kind, both plain and negated, checking the fragments
// combine and the arguments line up with the placeholders.
func TestSearchOpportunitiesQueryAllTerms(t *testing.T) {
db := openTestDB(t)
query := search.Parse(`cloud "road repairs" title:a title:b buyer:c ` +
`buyer_id:GB-1 buyer_id:GB-2 cpv:72 cpv:food !cloud !title:d ` +
`!buyer:e !buyer_id:GB-3 !cpv:7221 !cpv:fuel`)
statement, args := searchOpportunitiesQuery(
query, time.Now().Add(-time.Hour),
)
statement, args, err := sqlx.In(statement, args...)
if err != nil {
t.Fatal(err)
}
var uuids []string
if err := db.Select(&uuids, db.Rebind(statement), args...); err != nil {
t.Errorf("run with %d args:\n%s\n%v", len(args), statement, 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) {