Address Gadfly review on seed lots
Build image / build-and-push (push) Successful in 15s

Three real bugs, two of which would have quietly corrupted inventory numbers.

CopyGarden carried seed_lot_id into the copied plantings, so copying a garden
double-counted the original lot's usage — the copy is a plan, not a second
planting out of the same packet. It also quietly attached a private lot to a
garden that may later be shared, contradicting the "lots are never shared with a
garden" invariant this feature was built on. The copy now drops the link.

RestorePlanting restored a seed_lot_id verbatim from a history snapshot. Live
rows get their link nulled by ON DELETE SET NULL when a lot is deleted, but the
snapshot still holds the old id, so undoing a planting deletion after retiring
its lot would violate the foreign key and abort the whole revert. The revert now
drops a dangling link before restoring: the plant really was in the ground,
which is the part worth restoring.

CreateSeedLot returned a lot with Used/Remaining at their zero values, so a
packet you just bought reported "0 remaining" — the exact opposite of the truth.
The version-conflict path had the same gap, and that row is what the client
rebases on, so a 409 reporting remaining=0 is worse than no number at all. Both
fill them now.

Remaining can go negative and that is deliberate — it means more was planted
than the lot recorded buying, which happens (a miscounted packet, a lot entered
after the fact). Clamping to zero would hide the discrepancy at exactly the
moment it is worth seeing. Now documented and tested rather than incidental.

Performance and tidiness: SeedLotUsage is scoped to the lots being filled
instead of scanning every planting the owner has, so reading one lot is one
lot's work; ListSeedLotsForOwner gained the LIMIT every other list read has;
parseNullable moved out of seed_lots.go into the shared request helpers, since
every nullable field in the API needs that three-way absent/null/value
distinction; finalizePlant validates against its own maxPlantVendorLen rather
than borrowing a constant named for seed lots; seedLotForPlanting became
checkSeedLotForPlanting, since it validates rather than fetches; and the
duplicate intPtr test helper gave way to the existing ptrInt.

Declined: recording seed-lot mutations in the revision history. change_sets are
anchored to a garden and lots are user-scoped, deliberately — they belong to
you, not to any one garden, so there is no garden to record them against.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-21 01:38:54 -04:00
co-authored by Claude Opus 4.8
parent 5eedcaf945
commit 94aed26e14
9 changed files with 284 additions and 47 deletions
+21
View File
@@ -1,6 +1,7 @@
package api
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
@@ -69,6 +70,26 @@ func writeVersionConflict(c *gin.Context, current any) {
})
}
// parseNullable decodes any JSON value into (value, present) for a nullable
// column: absent → (nil, false); explicit null → (nil, true); anything else →
// the decoded value. That three-way distinction is what lets a PATCH tell "clear
// this to NULL" apart from "leave it alone", and every nullable field in the API
// needs it, so it lives here rather than in whichever file happened to want it
// first.
func parseNullable[T any](raw json.RawMessage) (*T, bool, error) {
if len(raw) == 0 {
return nil, false, nil
}
if string(raw) == "null" {
return nil, true, nil
}
var v T
if err := json.Unmarshal(raw, &v); err != nil {
return nil, false, err
}
return &v, true, nil
}
// intQuery reads a non-negative integer query parameter, falling back to def on
// an absent or malformed value.
func intQuery(c *gin.Context, name string, def int) int {
-16
View File
@@ -81,22 +81,6 @@ func (r seedLotUpdateRequest) toPatch() (service.SeedLotPatch, error) {
return p, nil
}
// parseNullable decodes any JSON value into (value, present) for a nullable
// column. Absent → (nil, false); null → (nil, true); otherwise the decoded value.
func parseNullable[T any](raw json.RawMessage) (*T, bool, error) {
if len(raw) == 0 {
return nil, false, nil
}
if string(raw) == "null" {
return nil, true, nil
}
var v T
if err := json.Unmarshal(raw, &v); err != nil {
return nil, false, err
}
return &v, true, nil
}
func (h *handlers) listSeedLots(c *gin.Context) {
var plantID *int64
if raw := c.Query("plantId"); raw != "" {
+2 -2
View File
@@ -107,7 +107,7 @@ func (s *Service) CreatePlanting(ctx context.Context, actorID, objectID int64, i
if err := finalizePlanting(p, o, true); err != nil {
return nil, err
}
if err := s.seedLotForPlanting(ctx, actorID, p.SeedLotID, p.PlantID); err != nil {
if err := s.checkSeedLotForPlanting(ctx, actorID, p.SeedLotID, p.PlantID); err != nil {
return nil, err
}
created, err := s.store.CreatePlanting(ctx, p)
@@ -159,7 +159,7 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
}
// Re-checked on every update, not just when the lot changes: a plant swap can
// leave a previously-valid attribution pointing at a lot of the wrong variety.
if err := s.seedLotForPlanting(ctx, actorID, pl.SeedLotID, pl.PlantID); err != nil {
if err := s.checkSeedLotForPlanting(ctx, actorID, pl.SeedLotID, pl.PlantID); err != nil {
return nil, err
}
pl.Version = version
+2 -1
View File
@@ -18,6 +18,7 @@ const (
minPlantSpacingCM = 0.1
maxPlantSpacingCM = 10_000 // 100 m — headroom for large trees/shrubs
maxDaysToMaturity = 3650 // ~10 years
maxPlantVendorLen = 200
)
// plantCategories is the set of valid category enum values (mirrors the schema
@@ -211,7 +212,7 @@ func finalizePlant(p *domain.Plant) error {
if len(p.Notes) > maxPlantNotesLen {
return domain.ErrInvalidInput
}
if len(p.Vendor) > maxLotTextLen {
if len(p.Vendor) > maxPlantVendorLen {
return domain.ErrInvalidInput
}
// Rendered as a clickable link, so the scheme check is load-bearing (see
+24
View File
@@ -417,6 +417,9 @@ type revertOps[T any] struct {
// remove undoes a creation. It returns the changes it made, because deleting
// an object also deletes the plantings hanging off it.
remove func(ctx context.Context, row *T) ([]change, error)
// beforeRestore fixes up a snapshot whose references may have gone stale
// while it sat in history.
beforeRestore func(ctx context.Context, row *T) error
// blockRemoval optionally refuses a removal, e.g. an object that has gained
// plantings since. Returns a conflict reason, or "" to allow it.
blockRemoval func(ctx context.Context, row *T) (string, error)
@@ -445,6 +448,11 @@ func revertEntity[T any](
if err := unsnapshot(r.Before, &target); err != nil {
return nil, nil, err
}
if ops.beforeRestore != nil {
if err := ops.beforeRestore(ctx, &target); err != nil {
return nil, nil, err
}
}
restored, err := ops.restore(ctx, &target)
if err != nil {
// The id may have been taken between the check above and this insert.
@@ -547,6 +555,22 @@ func plantingRevertOps(s *Service) revertOps[domain.Planting] {
get: s.store.GetPlanting,
update: s.store.UpdatePlanting,
restore: s.store.RestorePlanting,
// A snapshot can name a seed lot that has since been deleted. The live
// rows got their link nulled by ON DELETE SET NULL, but the snapshot
// still holds the old id, and restoring it would violate the foreign key
// and abort the whole revert. Drop the dangling link instead: the plant
// really was in the ground, which is the part worth restoring.
beforeRestore: func(ctx context.Context, p *domain.Planting) error {
if p.SeedLotID == nil {
return nil
}
if _, err := s.store.GetSeedLot(ctx, *p.SeedLotID); errors.Is(err, domain.ErrNotFound) {
p.SeedLotID = nil
} else if err != nil {
return err
}
return nil
},
remove: func(ctx context.Context, p *domain.Planting) ([]change, error) {
if err := s.store.DeletePlanting(ctx, p.ID); err != nil {
return nil, err
+49 -16
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"log/slog"
"net/url"
"strings"
@@ -90,11 +91,19 @@ func (s *Service) ListSeedLots(ctx context.Context, actorID int64, plantID *int6
// when you plant — drifts the moment anything edits or removes a planting behind
// its back, and once it has drifted there is no way to tell what the true number
// was. Attribution plus arithmetic can always be recomputed and audited.
// Remaining may go NEGATIVE, and that is deliberate: it means more was planted
// than the lot recorded buying, which is a real thing that happens (a
// miscounted packet, a lot entered after the fact). Clamping it to zero would
// hide the discrepancy at exactly the moment it is worth seeing.
func (s *Service) fillRemaining(ctx context.Context, actorID int64, lots []domain.SeedLot) error {
if len(lots) == 0 {
return nil
}
uses, err := s.store.SeedLotUsage(ctx, actorID)
ids := make([]int64, 0, len(lots))
for i := range lots {
ids = append(ids, lots[i].ID)
}
uses, err := s.store.SeedLotUsage(ctx, actorID, ids)
if err != nil {
return err
}
@@ -115,11 +124,12 @@ func (s *Service) fillRemaining(ctx context.Context, actorID int64, lots []domai
// ownSeedLot loads a lot and enforces that the actor owns it. Someone else's lot
// is ErrNotFound, not ErrForbidden — existence stays masked, same as gardens and
// other users' plants.
// other users' plants. A store failure that isn't "no such row" passes through
// unchanged; it is not an authorization answer.
func (s *Service) ownSeedLot(ctx context.Context, actorID, lotID int64) (*domain.SeedLot, error) {
l, err := s.store.GetSeedLot(ctx, lotID)
if err != nil {
return nil, err // ErrNotFound
return nil, err // ErrNotFound for a missing row; anything else is real
}
if l.OwnerID != actorID {
return nil, domain.ErrNotFound
@@ -133,11 +143,7 @@ func (s *Service) GetSeedLot(ctx context.Context, actorID, lotID int64) (*domain
if err != nil {
return nil, err
}
one := []domain.SeedLot{*l}
if err := s.fillRemaining(ctx, actorID, one); err != nil {
return nil, err
}
return &one[0], nil
return s.withRemaining(ctx, actorID, l)
}
// CreateSeedLot records a purchase owned by the actor. The plant must be one the
@@ -165,7 +171,23 @@ func (s *Service) CreateSeedLot(ctx context.Context, actorID int64, in SeedLotIn
if err := finalizeSeedLot(l); err != nil {
return nil, err
}
return s.store.CreateSeedLot(ctx, l)
created, err := s.store.CreateSeedLot(ctx, l)
if err != nil {
return nil, err
}
// A fresh lot has used nothing, but say so explicitly rather than letting the
// zero value stand in: an unfilled Remaining reads as "none left" on a packet
// you just bought.
return s.withRemaining(ctx, actorID, created)
}
// withRemaining fills Used/Remaining on a single lot.
func (s *Service) withRemaining(ctx context.Context, actorID int64, l *domain.SeedLot) (*domain.SeedLot, error) {
one := []domain.SeedLot{*l}
if err := s.fillRemaining(ctx, actorID, one); err != nil {
return nil, err
}
return &one[0], nil
}
// UpdateSeedLot applies a partial, version-guarded patch to the actor's own lot.
@@ -181,13 +203,24 @@ func (s *Service) UpdateSeedLot(ctx context.Context, actorID, lotID int64, patch
l.Version = version
updated, err := s.store.UpdateSeedLot(ctx, l)
if err != nil {
// The conflict path carries the CURRENT row back to the client to rebase
// on, so it needs its computed fields too — a 409 body reporting
// remaining=0 would be worse than no number at all.
if updated != nil {
if filled, ferr := s.withRemaining(ctx, actorID, updated); ferr == nil {
updated = filled
}
}
return updated, err
}
one := []domain.SeedLot{*updated}
if ferr := s.fillRemaining(ctx, actorID, one); ferr != nil {
return nil, ferr
// The write succeeded. If the follow-up read fails, return the row without
// its computed fields rather than reporting a failed update that applied.
filled, ferr := s.withRemaining(ctx, actorID, updated)
if ferr != nil {
slog.Error("service: lot updated but remaining could not be computed", "error", ferr, "lot", lotID)
return updated, nil
}
return &one[0], nil
return filled, nil
}
// DeleteSeedLot removes the actor's own lot. Plantings attributed to it survive
@@ -200,11 +233,11 @@ func (s *Service) DeleteSeedLot(ctx context.Context, actorID, lotID int64) error
return s.store.DeleteSeedLot(ctx, lotID)
}
// seedLotForPlanting validates a planting's lot attribution: the lot must be the
// actor's, and must be a lot OF the plant being planted. Attributing a garlic
// checkSeedLotForPlanting validates a planting's lot attribution: the lot must be
// the actor's, and must be a lot OF the plant being planted. Attributing a garlic
// planting to a tomato lot is a data error that would silently corrupt every
// remaining count downstream, so it's refused rather than stored.
func (s *Service) seedLotForPlanting(ctx context.Context, actorID int64, lotID *int64, plantID int64) error {
func (s *Service) checkSeedLotForPlanting(ctx context.Context, actorID int64, lotID *int64, plantID int64) error {
if lotID == nil {
return nil
}
+153 -5
View File
@@ -53,11 +53,11 @@ func TestTwoLotsOfOnePlantTrackIndependently(t *testing.T) {
johnnys := seedLot(t, s, owner, plant.ID, 100, func(in *SeedLotInput) {
in.Vendor = "Johnny's"
in.SourceURL = "https://www.johnnyseeds.com/vegetables/garlic/german-red-garlic"
in.PackedForYear = intPtr(2026)
in.PackedForYear = ptrInt(2026)
})
fedco := seedLot(t, s, owner, plant.ID, 50, func(in *SeedLotInput) {
in.Vendor = "Fedco"
in.PackedForYear = intPtr(2025)
in.PackedForYear = ptrInt(2025)
})
// Plant 12 out of the Johnny's lot only.
@@ -342,8 +342,6 @@ func TestSeedLotUnitAndQuantityValidation(t *testing.T) {
}
}
func intPtr(v int) *int { return &v }
// TestDeletingAPlantWithLotsIsRefused — seed_lots.plant_id is ON DELETE CASCADE,
// so without a guard, deleting a plant would silently destroy the purchase
// records attached to it: vendor, cost, germination rate, gone with no warning.
@@ -351,7 +349,7 @@ func TestDeletingAPlantWithLotsIsRefused(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
plant := seedOwnPlant(t, s, owner, 15)
lot := seedLot(t, s, owner, plant.ID, 100, func(in *SeedLotInput) { in.CostCents = intPtr(499) })
lot := seedLot(t, s, owner, plant.ID, 100, func(in *SeedLotInput) { in.CostCents = ptrInt(499) })
ctx := context.Background()
if err := s.DeletePlant(ctx, owner, plant.ID); !errors.Is(err, domain.ErrPlantInUse) {
@@ -369,3 +367,153 @@ func TestDeletingAPlantWithLotsIsRefused(t *testing.T) {
t.Errorf("DeletePlant after clearing lots: %v", err)
}
}
// TestFreshLotReportsItsFullQuantity — an unfilled Remaining reads as "none
// left" on a packet you just bought, which is the opposite of the truth.
func TestFreshLotReportsItsFullQuantity(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
plant := seedOwnPlant(t, s, owner, 15)
lot := seedLot(t, s, owner, plant.ID, 100, nil)
if lot.Remaining != 100 || lot.Used != 0 {
t.Errorf("fresh lot: used=%v remaining=%v, want 0/100", lot.Used, lot.Remaining)
}
}
// TestVersionConflictCarriesRemaining — the 409 body is what the client rebases
// on, so it needs the computed fields too.
func TestVersionConflictCarriesRemaining(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
lot := seedLot(t, s, owner, plant.ID, 100, nil)
ctx := context.Background()
ten := 10
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID,
}); err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
if _, err := s.UpdateSeedLot(ctx, owner, lot.ID, SeedLotPatch{Notes: strPtr("first")}, lot.Version); err != nil {
t.Fatalf("first update: %v", err)
}
current, err := s.UpdateSeedLot(ctx, owner, lot.ID, SeedLotPatch{Notes: strPtr("stale")}, lot.Version)
if !errors.Is(err, domain.ErrVersionConflict) {
t.Fatalf("err = %v, want ErrVersionConflict", err)
}
if current == nil || current.Remaining != 90 || current.Used != 10 {
t.Errorf("conflict row: used=%v remaining=%v, want 10/90", current.Used, current.Remaining)
}
}
// TestRemainingGoesNegativeRatherThanHidingIt — planting more than you recorded
// buying is a real thing; clamping to zero would hide the discrepancy at exactly
// the moment it is worth seeing.
func TestRemainingGoesNegativeRatherThanHidingIt(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
lot := seedLot(t, s, owner, plant.ID, 5, nil)
ctx := context.Background()
twenty := 20
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &twenty, SeedLotID: &lot.ID,
}); err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
got, err := s.GetSeedLot(ctx, owner, lot.ID)
if err != nil {
t.Fatalf("GetSeedLot: %v", err)
}
if got.Remaining != -15 {
t.Errorf("remaining = %v, want -15 (20 planted from a lot of 5)", got.Remaining)
}
}
// TestCopiedGardenDoesNotInheritSeedLots — a copy is a plan, not a second
// planting out of the same packet. Carrying the link would double-count the
// original lot's usage and quietly attach a private lot to a garden that may
// later be shared.
func TestCopiedGardenDoesNotInheritSeedLots(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
lot := seedLot(t, s, owner, plant.ID, 100, nil)
ctx := context.Background()
ten := 10
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID,
}); err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
copied, err := s.CopyGarden(ctx, owner, g.ID, "Copy")
if err != nil {
t.Fatalf("CopyGarden: %v", err)
}
full, err := s.GardenFull(ctx, owner, copied.ID)
if err != nil {
t.Fatalf("GardenFull: %v", err)
}
if len(full.Plantings) != 1 {
t.Fatalf("copy has %d plantings, want 1", len(full.Plantings))
}
if full.Plantings[0].SeedLotID != nil {
t.Errorf("the copy inherited seed lot %v", *full.Plantings[0].SeedLotID)
}
// And the original lot's usage is unchanged: still 10, not 20.
got, _ := s.GetSeedLot(ctx, owner, lot.ID)
if got.Used != 10 {
t.Errorf("copying double-counted the lot: used = %v, want 10", got.Used)
}
}
// TestRevertRestoresAPlantingWhoseLotIsGone — a snapshot can name a lot that has
// since been deleted; restoring it verbatim would violate the FK and abort the
// whole revert.
func TestRevertRestoresAPlantingWhoseLotIsGone(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
lot := seedLot(t, s, owner, plant.ID, 100, nil)
ctx := context.Background()
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, SeedLotID: &lot.ID,
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
if err := s.DeletePlanting(ctx, owner, pl.ID); err != nil {
t.Fatalf("DeletePlanting: %v", err)
}
del := history(t, s, owner, g.ID)[0]
// The lot goes away while the deletion sits in history.
if err := s.DeleteSeedLot(ctx, owner, lot.ID); err != nil {
t.Fatalf("DeleteSeedLot: %v", err)
}
if _, conflicts, err := s.RevertChangeSet(ctx, owner, del.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
}
restored, err := s.store.GetPlanting(ctx, pl.ID)
if err != nil {
t.Fatalf("planting not restored: %v", err)
}
if restored.SeedLotID != nil {
t.Errorf("a dangling lot reference was restored: %v", *restored.SeedLotID)
}
}
+5
View File
@@ -234,6 +234,11 @@ func (d *DB) CopyGarden(ctx context.Context, srcID, ownerID int64, name string)
// Unreachable: every active planting joins an object we just copied.
return nil, fmt.Errorf("store: copy planting %d: source object %d not copied", p.ID, p.ObjectID)
}
// A copy must not inherit the source's seed-lot attribution. The copied
// plops are a plan, not a second planting out of the same packet, and
// carrying the link would double-count that lot's usage — and quietly
// attach a private lot to a garden that may later be shared.
p.SeedLotID = nil
if _, err := scanPlanting(tx.QueryRowContext(ctx, plantingInsert, plantingInsertArgs(objectID, &p)...)); err != nil {
return nil, fmt.Errorf("store: copy planting: %w", err)
}
+28 -7
View File
@@ -9,6 +9,10 @@ import (
"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,
@@ -67,8 +71,11 @@ func (d *DB) ListSeedLotsForOwner(ctx context.Context, ownerID int64, plantID *i
query += ` AND plant_id = ?`
args = append(args, *plantID)
}
// NULL purchased_at sorts last: an undated lot is the least useful to see first.
query += ` ORDER BY purchased_at IS NULL, purchased_at DESC, id DESC`
// 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 {
@@ -161,21 +168,35 @@ type SeedLotUse struct {
SpacingCM float64
}
// SeedLotUsage returns the ACTIVE plantings attributed to any of the owner's
// lots — the "used" half of a lot's remaining quantity.
// 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.
func (d *DB) SeedLotUsage(ctx context.Context, ownerID int64) ([]SeedLotUse, error) {
//
// 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`,
ownerID)
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)
}