Plant catalog backend: CRUD + seeded built-ins (#12)
Build image / build-and-push (push) Successful in 5s
Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #31.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
-- 0003_seed_plants.sql — built-in plant catalog.
|
||||
--
|
||||
-- owner_id NULL marks a read-only built-in (see 0001_init.sql § plants). These
|
||||
-- seed once: the migration runner records this version in schema_migrations, so
|
||||
-- re-running Migrate is a no-op and never duplicates them. Users clone a built-in
|
||||
-- (POST a copy owned by themselves) to customize; the originals stay immutable.
|
||||
--
|
||||
-- spacing_cm is mature in-row spacing and drives derived plop counts (#14).
|
||||
-- color is a display hex; icon is an emoji (v1 ships zero image assets).
|
||||
|
||||
INSERT INTO plants (owner_id, name, category, spacing_cm, color, icon) VALUES
|
||||
-- Alliums & vegetables
|
||||
(NULL, 'Garlic', 'vegetable', 15, '#d9d2c5', '🧄'),
|
||||
(NULL, 'Onion', 'vegetable', 10, '#b07fc7', '🧅'),
|
||||
(NULL, 'Bush bean', 'vegetable', 10, '#6b8e23', '🫘'),
|
||||
(NULL, 'Pole bean', 'vegetable', 15, '#4e7a27', '🫘'),
|
||||
(NULL, 'Pea', 'vegetable', 8, '#8bc34a', '🫛'),
|
||||
(NULL, 'Tomato', 'vegetable', 60, '#e53935', '🍅'),
|
||||
(NULL, 'Pepper', 'vegetable', 45, '#d32f2f', '🌶️'),
|
||||
(NULL, 'Cucumber', 'vegetable', 30, '#43a047', '🥒'),
|
||||
(NULL, 'Zucchini', 'vegetable', 60, '#2e7d32', '🥒'),
|
||||
(NULL, 'Winter squash', 'vegetable', 90, '#ef6c00', '🎃'),
|
||||
(NULL, 'Carrot', 'vegetable', 8, '#f57c00', '🥕'),
|
||||
(NULL, 'Radish', 'vegetable', 5, '#e91e63', '🌱'),
|
||||
(NULL, 'Beet', 'vegetable', 10, '#8e24aa', '🌱'),
|
||||
(NULL, 'Lettuce', 'vegetable', 20, '#7cb342', '🥬'),
|
||||
(NULL, 'Spinach', 'vegetable', 10, '#33691e', '🥬'),
|
||||
(NULL, 'Kale', 'vegetable', 45, '#2e5d34', '🥬'),
|
||||
(NULL, 'Broccoli', 'vegetable', 45, '#3f7d3f', '🥦'),
|
||||
(NULL, 'Cabbage', 'vegetable', 45, '#8bc98b', '🥬'),
|
||||
(NULL, 'Potato', 'vegetable', 30, '#a1887f', '🥔'),
|
||||
(NULL, 'Corn', 'vegetable', 25, '#fbc02d', '🌽'),
|
||||
-- Herbs
|
||||
(NULL, 'Basil', 'herb', 25, '#4a7c3f', '🌿'),
|
||||
(NULL, 'Cilantro', 'herb', 10, '#6aa84f', '🌿'),
|
||||
(NULL, 'Dill', 'herb', 15, '#7cb342', '🌿'),
|
||||
(NULL, 'Parsley', 'herb', 15, '#388e3c', '🌿'),
|
||||
(NULL, 'Oregano', 'herb', 25, '#558b2f', '🌿'),
|
||||
(NULL, 'Thyme', 'herb', 20, '#7a9b6e', '🌿'),
|
||||
(NULL, 'Rosemary', 'herb', 45, '#4b6b47', '🌿'),
|
||||
(NULL, 'Sage', 'herb', 45, '#9caf88', '🌿'),
|
||||
(NULL, 'Mint', 'herb', 30, '#66bb6a', '🌿'),
|
||||
-- Fruit & flowers
|
||||
(NULL, 'Strawberry', 'fruit', 30, '#e91e63', '🍓'),
|
||||
(NULL, 'Sunflower', 'flower', 45, '#fdd835', '🌻'),
|
||||
(NULL, 'Marigold', 'flower', 25, '#fb8c00', '🌼');
|
||||
@@ -2,6 +2,8 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
@@ -53,3 +55,132 @@ func (d *DB) ListReferencedPlants(ctx context.Context, gardenID int64) ([]domain
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// openTestDB opens a fresh file-backed DB in a temp dir and migrates it.
|
||||
@@ -38,12 +41,19 @@ func TestMigrateCreatesSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Derive the expected head version from the embedded files so adding a
|
||||
// migration doesn't break this test.
|
||||
migs, err := loadMigrations()
|
||||
if err != nil {
|
||||
t.Fatalf("loadMigrations: %v", err)
|
||||
}
|
||||
wantVersion := migs[len(migs)-1].version
|
||||
var version int
|
||||
if err := db.SQL().QueryRow(`SELECT max(version) FROM schema_migrations`).Scan(&version); err != nil {
|
||||
t.Fatalf("read schema_migrations: %v", err)
|
||||
}
|
||||
if version != 2 {
|
||||
t.Errorf("schema version = %d, want 2", version)
|
||||
if version != wantVersion {
|
||||
t.Errorf("schema version = %d, want %d", version, wantVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +64,16 @@ func TestMigrateIsIdempotent(t *testing.T) {
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("second Migrate: %v", err)
|
||||
}
|
||||
migs, err := loadMigrations()
|
||||
if err != nil {
|
||||
t.Fatalf("loadMigrations: %v", err)
|
||||
}
|
||||
var count int
|
||||
if err := db.SQL().QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil {
|
||||
t.Fatalf("count schema_migrations: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("schema_migrations rows = %d, want 2", count)
|
||||
if count != len(migs) {
|
||||
t.Errorf("schema_migrations rows = %d, want %d", count, len(migs))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +91,44 @@ func TestForeignKeysEnforced(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePlantForeignKeyBackstop(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Seed a user → garden → object → custom plant, then a planting referencing
|
||||
// the plant. DeletePlant must surface the ON DELETE RESTRICT FK as
|
||||
// ErrPlantInUse (not a raw 500-worthy error) — this pins isForeignKeyViolation's
|
||||
// string-match so a driver-message change is caught here rather than in prod.
|
||||
// The service normally blocks this via a count pre-check; here we hit the
|
||||
// store backstop directly (the lost-race path).
|
||||
insert := func(q string, args ...any) int64 {
|
||||
res, err := db.SQL().ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("seed exec %q: %v", q, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("last insert id: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
uid := insert(`INSERT INTO users (email, display_name) VALUES ('[email protected]', 'A')`)
|
||||
gid := insert(`INSERT INTO gardens (owner_id, name, width_cm, height_cm) VALUES (?, 'G', 100, 100)`, uid)
|
||||
oid := insert(`INSERT INTO garden_objects (garden_id, kind, x_cm, y_cm, width_cm, height_cm) VALUES (?, 'bed', 0, 0, 10, 10)`, gid)
|
||||
pid := insert(`INSERT INTO plants (owner_id, name, category, spacing_cm, color, icon) VALUES (?, 'Basil', 'herb', 25, '#4a7c3f', '🌿')`, uid)
|
||||
insert(`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm) VALUES (?, ?, 0, 0, 10)`, oid, pid)
|
||||
|
||||
if err := db.DeletePlant(ctx, pid); !errors.Is(err, domain.ErrPlantInUse) {
|
||||
t.Fatalf("DeletePlant on referenced plant = %v, want ErrPlantInUse", err)
|
||||
}
|
||||
|
||||
// Clearing the reference lets the delete succeed.
|
||||
insert(`DELETE FROM plantings WHERE plant_id = ?`, pid)
|
||||
if err := db.DeletePlant(ctx, pid); err != nil {
|
||||
t.Errorf("DeletePlant after clearing reference: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckConstraintRejectsBadEnum(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
|
||||
@@ -177,3 +177,10 @@ func boolToInt(b bool) int {
|
||||
func isUniqueViolation(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||
}
|
||||
|
||||
// isForeignKeyViolation reports whether err is a SQLite FOREIGN KEY-constraint
|
||||
// failure — e.g. deleting a plant a planting still references (ON DELETE
|
||||
// RESTRICT). Same stable-message approach as isUniqueViolation.
|
||||
func isForeignKeyViolation(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "FOREIGN KEY constraint failed")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user