First pass implementation of an SQLite backing store for fetched opportunities

This commit is contained in:
bwilliams committed 2026-10-13 12:52:17 +01:00
1 parent 9b0e6dccfe
commit 045492a148
29 files changed
+3132 -9

No files matched your search

+234
View File
@@ -0,0 +1,234 @@
package store
// All SQL used by the store, grouped by table in migration order.
//
// Conventions:
// - Statements are named <verb><Subject>, e.g. insertRelease. Fragments
// combined at run time are named after what they build (listOpportunities*,
// filterOpportunity*).
// - Named-parameter statements (":column", bound from a struct's db tags)
// say so in their comment. Everything else uses "?" placeholders.
// - Reads select whole rows (*, t.*, c.*) so a column added by a migration
// but not mapped to a struct field fails loudly; see opportunityRow.
// - Keep comments out of the SQL strings: sqlx reads a colon followed by a
// name as a parameter, even inside an SQL comment.
// - Every constant here is prepared against the migrated schema by
// TestQueriesPrepare (queries_test.go), which also fails if a constant is
// added without being listed there.
// ---------- opportunities ----------
// selectOpportunityLatestReleaseDate reads the date SaveOpportunity compares against.
const selectOpportunityLatestReleaseDate = `
SELECT latest_release_date FROM opportunities WHERE uuid = ?`
// upsertOpportunity inserts or updates a opportunity. Named parameters (types.Opportunity).
//
// An UPDATE on conflict, not a REPLACE: REPLACE would delete the row first,
// cascading to the opportunity's releases and changing its id.
const upsertOpportunity = `
INSERT INTO opportunities (
uuid, uuid_type, title, status, description, buyer_name, buyer_id,
main_category, procurement_method, multi_lot, sme_suitable, vcse_suitable,
start_date, end_date, gross_value, has_documents, latest_release_date
) VALUES (
:uuid, :uuid_type, :title, :status, :description, :buyer_name, :buyer_id,
:main_category, :procurement_method, :multi_lot, :sme_suitable, :vcse_suitable,
:start_date, :end_date, :gross_value, :has_documents, :latest_release_date
)
ON CONFLICT (uuid) DO UPDATE SET
uuid_type = excluded.uuid_type,
title = excluded.title,
status = excluded.status,
description = excluded.description,
buyer_name = excluded.buyer_name,
buyer_id = excluded.buyer_id,
main_category = excluded.main_category,
procurement_method = excluded.procurement_method,
multi_lot = excluded.multi_lot,
sme_suitable = excluded.sme_suitable,
vcse_suitable = excluded.vcse_suitable,
start_date = excluded.start_date,
end_date = excluded.end_date,
gross_value = excluded.gross_value,
has_documents = excluded.has_documents,
latest_release_date = excluded.latest_release_date`
// selectOpportunityByUUID reads one opportunity into a opportunityRow.
const selectOpportunityByUUID = `
SELECT t.* FROM opportunities t WHERE t.uuid = ?`
// ListOpportunities is assembled by listOpportunitiesQuery as:
//
// listOpportunitiesSelect [WHERE filter AND filter ...] listOpportunitiesOrderAndPage
//
// Each filterOpportunity* fragment takes exactly one "?" argument, except
// filterOpportunityMark* fragments, which take none. A mark fragment is true
// when the mark is set and is negated with NOT to match it unset; an
// opportunity with no marks row has every mark unset.
const (
listOpportunitiesSelect = `
SELECT t.* FROM opportunities t`
listOpportunitiesOrderAndPage = `
ORDER BY t.latest_release_date DESC, t.uuid
LIMIT ? OFFSET ?`
filterOpportunityStatus = `t.status = ?`
filterOpportunityClassificationCode = `EXISTS (
SELECT 1
FROM opportunity_classifications tc
JOIN classification_codes c ON c.id = tc.classification_id
WHERE tc.opportunity_uuid = t.uuid AND c.code = ?
)`
// The argument must come from ftsQuery, never raw user input.
filterOpportunityTitleMatch = `t.id IN (
SELECT rowid FROM opportunities_fts WHERE opportunities_fts MATCH ?
)`
filterOpportunityReleasedFrom = `t.latest_release_date >= ?`
filterOpportunityReleasedBefore = `t.latest_release_date < ?`
filterOpportunityMarkHidden = `EXISTS (
SELECT 1 FROM opportunity_marks m WHERE m.uuid = t.uuid AND m.hidden
)`
filterOpportunityMarkSkipped = `EXISTS (
SELECT 1 FROM opportunity_marks m WHERE m.uuid = t.uuid AND m.skipped
)`
filterOpportunityMarkInterested = `EXISTS (
SELECT 1 FROM opportunity_marks m WHERE m.uuid = t.uuid AND m.interested
)`
filterOpportunityMarkBidSubmitted = `EXISTS (
SELECT 1 FROM opportunity_marks m WHERE m.uuid = t.uuid AND m.bid_submitted
)`
filterOpportunityMarkWon = `EXISTS (
SELECT 1 FROM opportunity_marks m WHERE m.uuid = t.uuid AND m.won
)`
filterOpportunityMarkLost = `EXISTS (
SELECT 1 FROM opportunity_marks m WHERE m.uuid = t.uuid AND m.lost
)`
)
// ---------- opportunity_releases ----------
// insertRelease stores a release; 0 rows affected means it was already
// stored. Named parameters (types.OCDSReleaseJSON).
const insertRelease = `
INSERT INTO opportunity_releases (uuid, release_id, release_date, data)
VALUES (:uuid, :release_id, :release_date, :data)
ON CONFLICT DO NOTHING`
// selectReleasesByOpportunity reads a opportunity's releases, oldest first.
const selectReleasesByOpportunity = `
SELECT *
FROM opportunity_releases
WHERE uuid = ?
ORDER BY release_date, release_id`
// selectReleaseExists returns a single boolean.
const selectReleaseExists = `
SELECT EXISTS (
SELECT 1 FROM opportunity_releases WHERE uuid = ? AND release_id = ?
)`
// ---------- opportunity_release_tags ----------
// insertReleaseTag stores a tag. Named parameters (types.OCDSReleaseTags).
const insertReleaseTag = `
INSERT INTO opportunity_release_tags (uuid, release_id, tag)
VALUES (:uuid, :release_id, :tag)
ON CONFLICT DO NOTHING`
// selectReleaseTagsByOpportunity reads the tags of all of a opportunity's releases.
const selectReleaseTagsByOpportunity = `
SELECT *
FROM opportunity_release_tags
WHERE uuid = ?
ORDER BY release_id, tag`
// ---------- classification_codes ----------
// upsertClassificationCodeReturningID returns the id of the row matching
// every content column, inserting it first if needed. Named parameters
// (types.ClassificationCode).
//
// The no-op DO UPDATE (rather than DO NOTHING) makes RETURNING yield the
// existing row's id on conflict.
const upsertClassificationCodeReturningID = `
INSERT INTO classification_codes (is_cpv, code, classification)
VALUES (:is_cpv, :code, :classification)
ON CONFLICT (is_cpv, code, classification) DO UPDATE SET is_cpv = excluded.is_cpv
RETURNING id`
// ---------- opportunity_classifications ----------
// deleteOpportunityClassifications clears a opportunity's mappings before they are
// rewritten.
const deleteOpportunityClassifications = `
DELETE FROM opportunity_classifications WHERE opportunity_uuid = ?`
// insertOpportunityClassification maps a opportunity to a classification code.
const insertOpportunityClassification = `
INSERT INTO opportunity_classifications (opportunity_uuid, classification_id)
VALUES (?, ?)
ON CONFLICT DO NOTHING`
// selectClassificationsForOpportunities reads the classifications of several
// opportunities. "IN (?)" is expanded by sqlx.In, which must be followed by Rebind.
// c.* must map entirely onto types.ClassificationCode.
const selectClassificationsForOpportunities = `
SELECT tc.opportunity_uuid, c.*
FROM opportunity_classifications tc
JOIN classification_codes c ON c.id = tc.classification_id
WHERE tc.opportunity_uuid IN (?)
ORDER BY tc.opportunity_uuid, c.code, c.id`
// ---------- opportunity_marks ----------
// insertOpportunityMarks gives a new opportunity its marks row; an existing
// row is left alone, so saving fetched data never resets marks set by the
// user. Named parameters (types.OpportunityMarks).
const insertOpportunityMarks = `
INSERT INTO opportunity_marks (
uuid, hidden, interested, skipped, bid_submitted, won, lost
)
VALUES (
:uuid, :hidden, :interested, :skipped, :bid_submitted, :won, :lost
)
ON CONFLICT (uuid) DO NOTHING`
// upsertOpportunityMarks inserts or updates an opportunity's marks. Named
// parameters (types.OpportunityMarks).
//
// An UPDATE on conflict, not a REPLACE: REPLACE would delete the row first
// which is unnecessary.
const upsertOpportunityMarks = `
INSERT INTO opportunity_marks (
uuid, hidden, interested, skipped, bid_submitted, won, lost
)
VALUES (
:uuid, :hidden, :interested, :skipped, :bid_submitted, :won, :lost
)
ON CONFLICT (uuid) DO UPDATE SET
hidden = excluded.hidden,
interested = excluded.interested,
skipped = excluded.skipped,
bid_submitted = excluded.bid_submitted,
won = excluded.won,
lost = excluded.lost`
// selectMarksForOpportunities reads the marks of several opportunities.
// "IN (?)" is expanded by sqlx.In, which must be followed by Rebind.
const selectMarksForOpportunities = `
SELECT *
FROM opportunity_marks
WHERE uuid IN (?)
ORDER BY uuid`