Build image / build-and-push (push) Successful in 8s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
218 lines
7.7 KiB
Go
218 lines
7.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
)
|
|
|
|
// maxSeedLotsPerRead caps a lot listing. Far past any real seed shelf; it exists
|
|
// so the read is bounded rather than trusting it to stay small.
|
|
const maxSeedLotsPerRead = 1000
|
|
|
|
// seedLotColumns lists seed_lots columns in the order scanSeedLot expects.
|
|
const seedLotColumns = `id, owner_id, plant_id, vendor, source_url, sku, lot_code,
|
|
purchased_at, packed_for_year, quantity, unit, cost_cents, germination_pct, notes,
|
|
version, created_at, updated_at`
|
|
|
|
func scanSeedLot(s scanner) (*domain.SeedLot, error) {
|
|
var l domain.SeedLot
|
|
if err := s.Scan(
|
|
&l.ID, &l.OwnerID, &l.PlantID, &l.Vendor, &l.SourceURL, &l.SKU, &l.LotCode,
|
|
&l.PurchasedAt, &l.PackedForYear, &l.Quantity, &l.Unit, &l.CostCents,
|
|
&l.GerminationPct, &l.Notes, &l.Version, &l.CreatedAt, &l.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return &l, nil
|
|
}
|
|
|
|
// CreateSeedLot inserts a lot (fields already validated by the service).
|
|
func (d *DB) CreateSeedLot(ctx context.Context, l *domain.SeedLot) (*domain.SeedLot, error) {
|
|
created, err := scanSeedLot(d.sql.QueryRowContext(ctx,
|
|
`INSERT INTO seed_lots
|
|
(owner_id, plant_id, vendor, source_url, sku, lot_code, purchased_at,
|
|
packed_for_year, quantity, unit, cost_cents, germination_pct, notes)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
RETURNING `+seedLotColumns,
|
|
l.OwnerID, l.PlantID, l.Vendor, l.SourceURL, l.SKU, l.LotCode, l.PurchasedAt,
|
|
l.PackedForYear, l.Quantity, l.Unit, l.CostCents, l.GerminationPct, l.Notes,
|
|
))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: insert seed lot: %w", err)
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
// GetSeedLot returns a lot by id, or domain.ErrNotFound. Ownership is the
|
|
// service's check, not this one's.
|
|
func (d *DB) GetSeedLot(ctx context.Context, id int64) (*domain.SeedLot, error) {
|
|
l, err := scanSeedLot(d.sql.QueryRowContext(ctx,
|
|
`SELECT `+seedLotColumns+` FROM seed_lots WHERE id = ?`, id))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: get seed lot: %w", err)
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
// ListSeedLotsForOwner returns every lot the actor owns, newest purchase first
|
|
// (undated lots last), then by id. Optionally narrowed to one plant. Always a
|
|
// non-nil slice.
|
|
func (d *DB) ListSeedLotsForOwner(ctx context.Context, ownerID int64, plantID *int64) ([]domain.SeedLot, error) {
|
|
query := `SELECT ` + seedLotColumns + ` FROM seed_lots WHERE owner_id = ?`
|
|
args := []any{ownerID}
|
|
if plantID != nil {
|
|
query += ` AND plant_id = ?`
|
|
args = append(args, *plantID)
|
|
}
|
|
// NULL purchased_at sorts last: an undated lot is the least useful to see
|
|
// first. Capped like every other list read so one runaway account can't ask
|
|
// for an unbounded result set.
|
|
query += ` ORDER BY purchased_at IS NULL, purchased_at DESC, id DESC LIMIT ?`
|
|
args = append(args, maxSeedLotsPerRead)
|
|
|
|
rows, err := d.sql.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: list seed lots: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
lots := []domain.SeedLot{}
|
|
for rows.Next() {
|
|
l, err := scanSeedLot(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: scan seed lot: %w", err)
|
|
}
|
|
lots = append(lots, *l)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("store: iterate seed lots: %w", err)
|
|
}
|
|
return lots, nil
|
|
}
|
|
|
|
// UpdateSeedLot applies a version-guarded update of every mutable column (the
|
|
// service merges partial patches onto the current row first). Same contract as
|
|
// the other mutable resources: the updated row, or (current, ErrVersionConflict).
|
|
// plant_id and owner_id are immutable — re-pointing a purchase at a different
|
|
// variety or user would rewrite history rather than correct it.
|
|
func (d *DB) UpdateSeedLot(ctx context.Context, l *domain.SeedLot) (*domain.SeedLot, error) {
|
|
updated, err := scanSeedLot(d.sql.QueryRowContext(ctx,
|
|
`UPDATE seed_lots
|
|
SET vendor = ?, source_url = ?, sku = ?, lot_code = ?, purchased_at = ?,
|
|
packed_for_year = ?, quantity = ?, unit = ?, cost_cents = ?,
|
|
germination_pct = ?, notes = ?,
|
|
version = version + 1,
|
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
|
WHERE id = ? AND version = ?
|
|
RETURNING `+seedLotColumns,
|
|
l.Vendor, l.SourceURL, l.SKU, l.LotCode, l.PurchasedAt, l.PackedForYear,
|
|
l.Quantity, l.Unit, l.CostCents, l.GerminationPct, l.Notes,
|
|
l.ID, l.Version,
|
|
))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
current, gerr := d.GetSeedLot(ctx, l.ID)
|
|
if gerr != nil {
|
|
return nil, gerr
|
|
}
|
|
return current, domain.ErrVersionConflict
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: update seed lot: %w", err)
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// DeleteSeedLot removes a lot. Plantings attributed to it survive with a NULL
|
|
// seed_lot_id (see the migration): the plants really were in the ground,
|
|
// whatever happened to the record of the packet.
|
|
func (d *DB) DeleteSeedLot(ctx context.Context, id int64) error {
|
|
res, err := d.sql.ExecContext(ctx, `DELETE FROM seed_lots WHERE id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("store: delete seed lot: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("store: seed lot delete rows: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return domain.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CountSeedLotsForPlant returns how many lots reference a plant. Used to refuse
|
|
// deleting a plant that has purchase records: the FK is ON DELETE CASCADE, so
|
|
// without this check the delete would silently destroy them.
|
|
func (d *DB) CountSeedLotsForPlant(ctx context.Context, plantID int64) (int, error) {
|
|
var n int
|
|
if err := d.sql.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM seed_lots WHERE plant_id = ?`, plantID).Scan(&n); err != nil {
|
|
return 0, fmt.Errorf("store: count seed lots for plant: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// SeedLotUse is one active planting attributed to a lot, carrying just enough to
|
|
// compute its effective plant count.
|
|
type SeedLotUse struct {
|
|
LotID int64
|
|
RadiusCM float64
|
|
Count *int
|
|
SpacingCM float64
|
|
}
|
|
|
|
// SeedLotUsage returns the ACTIVE plantings attributed to the given lots — the
|
|
// "used" half of a lot's remaining quantity. Scoped to the lots the caller
|
|
// actually needs, so reading one lot doesn't scan every planting the owner has.
|
|
//
|
|
// It returns the raw ingredients rather than a SUM, because the effective count
|
|
// of a planting is its explicit count when it has one and the derived
|
|
// π·r²/spacing² formula otherwise. That formula lives in the service; reproducing
|
|
// it in SQL would guarantee the two drift apart.
|
|
//
|
|
// ownerID is still in the WHERE clause even though the ids come from rows the
|
|
// service already checked: a scoping query should not depend on its caller
|
|
// having checked scope.
|
|
func (d *DB) SeedLotUsage(ctx context.Context, ownerID int64, lotIDs []int64) ([]SeedLotUse, error) {
|
|
if len(lotIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
args := make([]any, 0, len(lotIDs)+1)
|
|
args = append(args, ownerID)
|
|
for _, id := range lotIDs {
|
|
args = append(args, id)
|
|
}
|
|
rows, err := d.sql.QueryContext(ctx,
|
|
`SELECT pl.seed_lot_id, pl.radius_cm, pl.count, p.spacing_cm
|
|
FROM plantings pl
|
|
JOIN seed_lots sl ON sl.id = pl.seed_lot_id
|
|
JOIN plants p ON p.id = pl.plant_id
|
|
WHERE sl.owner_id = ? AND pl.removed_at IS NULL
|
|
AND sl.id IN (`+placeholders(len(lotIDs))+`)`,
|
|
args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: seed lot usage: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
uses := []SeedLotUse{}
|
|
for rows.Next() {
|
|
var u SeedLotUse
|
|
if err := rows.Scan(&u.LotID, &u.RadiusCM, &u.Count, &u.SpacingCM); err != nil {
|
|
return nil, fmt.Errorf("store: scan seed lot usage: %w", err)
|
|
}
|
|
uses = append(uses, u)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("store: iterate seed lot usage: %w", err)
|
|
}
|
|
return uses, nil
|
|
}
|