Files
pansy/internal/store/plants.go
T
steveandClaude Opus 4.8 8f736048b9
Build image / build-and-push (push) Successful in 5s
Address Gadfly review on #14: bounds trap, date order, count cap
- UpdatePlanting: only re-check the object-bounds when the position is actually
  being moved. A plop orphaned outside its object by a later resize stays
  editable/removable instead of becoming a row you can't fix or delete.
- finalizePlanting: reject removed_at before planted_at.
- derivedCount: guard Inf (not just NaN) and cap the result at maxExplicitCount
  (1e6) — the same ceiling a manual override honors — so a huge radius / tiny
  spacing can't overflow a 32-bit int or return an absurd value.
- Refresh stale docs that referenced #14 as not-yet-landed (FullGarden,
  ListActivePlantingsForGarden, ListReferencedPlants) and note the date-only
  layout beside timeLayout.

Deliberately did NOT add a plantable re-check to Update/Delete: existing plops
must stay editable/removable even if their object was later marked non-plantable
(same trap as the bounds case).

Tests: derived-count cap, removed-before-planted rejection, and edit/move of a
plop orphaned by an object shrink.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 22:39:48 -04:00

187 lines
6.4 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// plantColumns lists plants columns in the order scanPlant expects.
const plantColumns = `id, owner_id, name, category, spacing_cm, color, icon,
days_to_maturity, notes, version, created_at, updated_at`
func scanPlant(s scanner) (*domain.Plant, error) {
var p domain.Plant
if err := s.Scan(
&p.ID, &p.OwnerID, &p.Name, &p.Category, &p.SpacingCM, &p.Color, &p.Icon,
&p.DaysToMaturity, &p.Notes, &p.Version, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, err
}
return &p, nil
}
// ListReferencedPlants returns the distinct plants used by a garden's active
// plantings — the catalog subset the editor needs to render them. Always a
// non-nil slice (empty when the garden has no active plantings). This is the
// /full read side; full plant CRUD/seeding lives in plants.go / plantings.go.
func (d *DB) ListReferencedPlants(ctx context.Context, gardenID int64) ([]domain.Plant, error) {
rows, err := d.sql.QueryContext(ctx,
`SELECT DISTINCT `+qualifyColumns("p", plantColumns)+` FROM plants p
JOIN plantings pl ON pl.plant_id = p.id
JOIN garden_objects o ON o.id = pl.object_id
WHERE o.garden_id = ? AND pl.removed_at IS NULL
ORDER BY p.id`,
gardenID,
)
if err != nil {
return nil, fmt.Errorf("store: list referenced plants: %w", err)
}
defer rows.Close()
plants := []domain.Plant{}
for rows.Next() {
p, err := scanPlant(rows)
if err != nil {
return nil, fmt.Errorf("store: scan plant: %w", err)
}
plants = append(plants, *p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate plants: %w", err)
}
return plants, nil
}
// maxPlantsListed caps ListPlantsForActor as a defensive backstop. The seeded
// built-ins (~30) plus a user's own catalog stay tiny at household scale, so this
// is never hit; pagination is post-v1 if ever needed.
const maxPlantsListed = 5000
// ListPlantsForActor returns the plants a user can see: every built-in
// (owner_id NULL) plus the user's own rows, built-ins first then by name. Always
// a non-nil slice. This is the catalog list; ListReferencedPlants above is the
// narrower /full read.
func (d *DB) ListPlantsForActor(ctx context.Context, actorID int64) ([]domain.Plant, error) {
rows, err := d.sql.QueryContext(ctx,
`SELECT `+plantColumns+` FROM plants
WHERE owner_id IS NULL OR owner_id = ?
ORDER BY owner_id IS NOT NULL, name COLLATE NOCASE, id
LIMIT ?`,
actorID, maxPlantsListed,
)
if err != nil {
return nil, fmt.Errorf("store: list plants: %w", err)
}
defer rows.Close()
plants := []domain.Plant{}
for rows.Next() {
p, err := scanPlant(rows)
if err != nil {
return nil, fmt.Errorf("store: scan plant: %w", err)
}
plants = append(plants, *p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate plants: %w", err)
}
return plants, nil
}
// GetPlant returns the plant with the given id, or domain.ErrNotFound. Visibility
// (built-in vs owned) is the service layer's call, not this raw read's.
func (d *DB) GetPlant(ctx context.Context, id int64) (*domain.Plant, error) {
p, err := scanPlant(d.sql.QueryRowContext(ctx,
`SELECT `+plantColumns+` FROM plants WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("store: get plant: %w", err)
}
return p, nil
}
// CreatePlant inserts a plant (fields already validated by the service) and
// returns the stored row. OwnerID is the creating user — built-ins are seeded by
// migration only, never through this path.
func (d *DB) CreatePlant(ctx context.Context, p *domain.Plant) (*domain.Plant, error) {
created, err := scanPlant(d.sql.QueryRowContext(ctx,
`INSERT INTO plants (owner_id, name, category, spacing_cm, color, icon, days_to_maturity, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING `+plantColumns,
p.OwnerID, p.Name, p.Category, p.SpacingCM, p.Color, p.Icon, p.DaysToMaturity, p.Notes,
))
if err != nil {
return nil, fmt.Errorf("store: insert plant: %w", err)
}
return created, nil
}
// UpdatePlant applies a version-guarded update of all mutable columns (the
// service merges partial patches first). Returns the updated row, or
// (current row, ErrVersionConflict) / ErrNotFound — the same contract as
// UpdateGarden/UpdateObject.
func (d *DB) UpdatePlant(ctx context.Context, p *domain.Plant) (*domain.Plant, error) {
updated, err := scanPlant(d.sql.QueryRowContext(ctx,
`UPDATE plants
SET name = ?, category = ?, spacing_cm = ?, color = ?, icon = ?,
days_to_maturity = ?, notes = ?,
version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ? AND version = ?
RETURNING `+plantColumns,
p.Name, p.Category, p.SpacingCM, p.Color, p.Icon, p.DaysToMaturity, p.Notes,
p.ID, p.Version,
))
if errors.Is(err, sql.ErrNoRows) {
current, gerr := d.GetPlant(ctx, p.ID)
if gerr != nil {
return nil, gerr
}
return current, domain.ErrVersionConflict
}
if err != nil {
return nil, fmt.Errorf("store: update plant: %w", err)
}
return updated, nil
}
// CountPlantingsForPlant returns how many plantings reference a plant, INCLUDING
// removed ones — a removed_at row still holds the FK (ON DELETE RESTRICT), so any
// row blocks deletion. The service checks this to refuse a delete with a clear
// error before the constraint fires.
func (d *DB) CountPlantingsForPlant(ctx context.Context, plantID int64) (int, error) {
var n int
if err := d.sql.QueryRowContext(ctx,
`SELECT count(*) FROM plantings WHERE plant_id = ?`, plantID).Scan(&n); err != nil {
return 0, fmt.Errorf("store: count plantings for plant: %w", err)
}
return n, nil
}
// DeletePlant removes a plant. Returns domain.ErrNotFound if no row was deleted,
// or domain.ErrPlantInUse if the ON DELETE RESTRICT foreign key from plantings
// fires (a backstop for the service's own count check under a concurrent insert).
func (d *DB) DeletePlant(ctx context.Context, id int64) error {
res, err := d.sql.ExecContext(ctx, `DELETE FROM plants WHERE id = ?`, id)
if err != nil {
if isForeignKeyViolation(err) {
return domain.ErrPlantInUse
}
return fmt.Errorf("store: delete plant: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("store: plant delete rows: %w", err)
}
if n == 0 {
return domain.ErrNotFound
}
return nil
}