162 lines
5.1 KiB
Go
162 lines
5.1 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// migration holds a SQL statement and a human-readable name.
|
|
// We run them in order and track which ones have already run.
|
|
type migration struct {
|
|
name string
|
|
sql string
|
|
}
|
|
|
|
var migrations = []migration{
|
|
{
|
|
name: "create_receipts",
|
|
sql: `
|
|
CREATE TABLE IF NOT EXISTS receipts (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
store_name TEXT,
|
|
receipt_date DATE NOT NULL,
|
|
image_path TEXT,
|
|
city TEXT,
|
|
country TEXT DEFAULT 'US',
|
|
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`,
|
|
},
|
|
{
|
|
name: "create_canonical_items",
|
|
sql: `
|
|
-- The "dictionary" of known items we track.
|
|
-- User submissions get mapped to these via fuzzy matching.
|
|
CREATE TABLE IF NOT EXISTS canonical_items (
|
|
name TEXT PRIMARY KEY, -- e.g. "milk_whole_1gal"
|
|
display_name TEXT NOT NULL, -- e.g. "Whole Milk, 1 Gallon"
|
|
category TEXT NOT NULL, -- e.g. "dairy"
|
|
unit TEXT, -- e.g. "gallon", "lb", "dozen"
|
|
aliases TEXT[] DEFAULT '{}' -- ["1 gal whole milk", "milk whole gal"]
|
|
);
|
|
`,
|
|
},
|
|
{
|
|
name: "create_line_items",
|
|
sql: `
|
|
CREATE TABLE IF NOT EXISTS line_items (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
receipt_id UUID NOT NULL REFERENCES receipts(id) ON DELETE CASCADE,
|
|
raw_name TEXT NOT NULL,
|
|
canonical_name TEXT REFERENCES canonical_items(name),
|
|
price_cents INT NOT NULL CHECK (price_cents > 0),
|
|
quantity NUMERIC(8,3) NOT NULL DEFAULT 1,
|
|
-- Stored computed column: unit price in cents
|
|
unit_price_cents INT GENERATED ALWAYS AS
|
|
(ROUND(price_cents / quantity)::INT) STORED
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS line_items_receipt_id ON line_items(receipt_id);
|
|
CREATE INDEX IF NOT EXISTS line_items_canonical ON line_items(canonical_name);
|
|
`,
|
|
},
|
|
{
|
|
name: "create_price_snapshots",
|
|
sql: `
|
|
-- Pre-aggregated monthly averages, rebuilt by a background job.
|
|
-- Charts read from here rather than scanning all line_items each time.
|
|
CREATE TABLE IF NOT EXISTS price_snapshots (
|
|
canonical_name TEXT NOT NULL REFERENCES canonical_items(name),
|
|
year_month DATE NOT NULL, -- always the 1st of the month
|
|
avg_price_cents INT NOT NULL,
|
|
sample_count INT NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (canonical_name, year_month)
|
|
);
|
|
`,
|
|
},
|
|
{
|
|
name: "seed_canonical_items",
|
|
sql: `
|
|
INSERT INTO canonical_items (name, display_name, category, unit, aliases) VALUES
|
|
('milk_whole_1gal', 'Whole Milk, 1 Gallon', 'dairy', 'gallon',
|
|
ARRAY['whole milk gallon', '1 gal whole milk', 'milk whl gal']),
|
|
('eggs_large_dozen', 'Large Eggs, 1 Dozen', 'dairy', 'dozen',
|
|
ARRAY['large eggs 12ct', 'eggs large dozen', 'grade a large eggs']),
|
|
('bread_white_loaf', 'White Bread, 1 Loaf', 'bakery', 'loaf',
|
|
ARRAY['white bread', 'sandwich bread', 'bread loaf']),
|
|
('ground_beef_1lb', 'Ground Beef, 1 lb (80%)', 'meat', 'lb',
|
|
ARRAY['ground beef lb', '80/20 ground beef', 'hamburger meat']),
|
|
('olive_oil_16oz', 'Olive Oil, 16 oz', 'pantry', 'bottle',
|
|
ARRAY['olive oil 16oz', 'extra virgin olive oil', 'evoo 16oz']),
|
|
('butter_salted_1lb','Salted Butter, 1 lb', 'dairy', 'lb',
|
|
ARRAY['butter salted pound', 'salted butter 4 sticks']),
|
|
('chicken_breast_1lb','Chicken Breast, 1 lb', 'meat', 'lb',
|
|
ARRAY['boneless chicken breast', 'chicken breast lb']),
|
|
('orange_juice_52oz','Orange Juice, 52 oz', 'beverages','carton',
|
|
ARRAY['oj 52oz', 'orange juice carton', 'florida natural oj'])
|
|
ON CONFLICT (name) DO NOTHING;
|
|
`,
|
|
},
|
|
}
|
|
|
|
// Migrate runs all pending migrations in order.
|
|
// It creates a simple tracking table on first run.
|
|
func Migrate(pool *pgxpool.Pool) error {
|
|
ctx := context.Background()
|
|
|
|
// Create the migrations tracking table if it doesn't exist
|
|
_, err := pool.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
name TEXT PRIMARY KEY,
|
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("create migrations table: %w", err)
|
|
}
|
|
|
|
for _, m := range migrations {
|
|
// Check if already applied
|
|
var exists bool
|
|
err := pool.QueryRow(ctx,
|
|
"SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE name = $1)", m.name,
|
|
).Scan(&exists)
|
|
if err != nil {
|
|
return fmt.Errorf("check migration %s: %w", m.name, err)
|
|
}
|
|
|
|
if exists {
|
|
continue
|
|
}
|
|
|
|
// Run the migration inside a transaction so it's atomic
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin migration %s: %w", m.name, err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, m.sql); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
return fmt.Errorf("run migration %s: %w", m.name, err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx,
|
|
"INSERT INTO schema_migrations (name) VALUES ($1)", m.name,
|
|
); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
return fmt.Errorf("record migration %s: %w", m.name, err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("commit migration %s: %w", m.name, err)
|
|
}
|
|
|
|
slog.Info("migration applied", "name", m.name)
|
|
}
|
|
|
|
return nil
|
|
}
|