Build image / build-and-push (push) Successful in 17s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
201 lines
7.1 KiB
Go
201 lines
7.1 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, source_url, vendor, 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.SourceURL, &p.Vendor, &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 plantings
|
|
// — the catalog subset the editor needs to render them. Always a non-nil slice.
|
|
//
|
|
// year nil means the active plops only. A year widens it to the plops that were
|
|
// in the ground THAT YEAR, which is what a past season needs: a plant pulled
|
|
// last July is not active, but its plops still have to render with the right
|
|
// icon and colour. It is scoped to the same year as the plops rather than to the
|
|
// garden's whole history, so the two selections always agree and a decade-old
|
|
// garden doesn't load a decade of catalog to draw one season.
|
|
func (d *DB) ListReferencedPlants(ctx context.Context, gardenID int64, year *int) ([]domain.Plant, error) {
|
|
where := ` AND pl.removed_at IS NULL`
|
|
args := []any{gardenID}
|
|
if year != nil {
|
|
where = ` AND (pl.planted_at IS NULL OR pl.planted_at <= ?)
|
|
AND (pl.removed_at IS NULL OR pl.removed_at >= ?)`
|
|
args = append(args, fmt.Sprintf("%04d-12-31", *year), fmt.Sprintf("%04d-01-01", *year))
|
|
}
|
|
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 = ?`+where+`
|
|
ORDER BY p.id`,
|
|
args...,
|
|
)
|
|
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, source_url, vendor, notes)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
RETURNING `+plantColumns,
|
|
p.OwnerID, p.Name, p.Category, p.SpacingCM, p.Color, p.Icon, p.DaysToMaturity,
|
|
p.SourceURL, p.Vendor, 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 = ?, source_url = ?, vendor = ?, 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.SourceURL, p.Vendor, 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
|
|
}
|