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 until plantings exist, #14). Full plant CRUD/seeding is // #12; this is only the /full read side. 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 }