"German Red Garlic" now links back to the Johnny's page it came from, and pansy knows how much is left. Two modelling calls, both of which look like omissions unless stated. The plant catalog stays FLAT — no species/variety hierarchy. A row in your catalog just is the thing you plant; the built-in "Garlic" is a generic starting point you clone. A hierarchy buys taxonomy nobody asked for and complicates every join. Inventory belongs to a PURCHASE, not to a variety. You might buy the same garlic from two vendors in two years at two prices and two germination rates; quantity columns on plants would collapse all of that into one number and lose it. So lots get their own table, and owner_id sits on the lot rather than being inherited from the plant — you can buy generic built-in Garlic from Johnny's and the record is still yours alone. Lots are never shared along with a garden. `remaining` is derived, never stored: quantity minus the summed effective count of the active plantings attributed to the lot. The alternative — decrementing a column when you plant — drifts the moment anything edits or removes a planting behind its back, and once it has drifted there's no way to tell what the true number was. Removing a plop gives the seed back with nothing having to remember to. The usage query returns raw radius/count/spacing rather than a SUM, because the effective-count formula lives in the service and reproducing it in SQL would guarantee the two drift apart. source_url is validated as http(s) with a host, on both plants and lots. It renders as a clickable link, so that check is load-bearing rather than tidiness: without it a stored javascript: URL becomes script execution the moment someone clicks it. Schemeless and relative URLs are refused too — they'd resolve against pansy's own origin, which is never what a vendor link means. A planting's lot must be the actor's AND a lot of the plant being planted. Attributing a garlic planting to a tomato lot would silently corrupt every remaining count downstream, so it's refused rather than stored, and re-checked on update in case a plant swap invalidated a previously-valid attribution. Deleting a lot leaves its plantings alone with a null link: the plants really were in the ground, whatever happened to the record of the packet. Closes #50 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
247 lines
9.6 KiB
Go
247 lines
9.6 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
)
|
|
|
|
// plantingColumns lists plantings columns in the order scanPlanting expects.
|
|
// Used unqualified for direct selects; the /full read below qualifies with pl.
|
|
const plantingColumns = `id, object_id, plant_id, x_cm, y_cm, radius_cm, count, label,
|
|
planted_at, removed_at, seed_lot_id, version, created_at, updated_at`
|
|
|
|
func scanPlanting(s scanner) (*domain.Planting, error) {
|
|
var p domain.Planting
|
|
if err := s.Scan(
|
|
&p.ID, &p.ObjectID, &p.PlantID, &p.XCM, &p.YCM, &p.RadiusCM,
|
|
&p.Count, &p.Label, &p.PlantedAt, &p.RemovedAt, &p.SeedLotID,
|
|
&p.Version, &p.CreatedAt, &p.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
// ListActivePlantingsForGarden returns every currently-planted plop (removed_at
|
|
// IS NULL) across all objects in a garden — the editor's one-shot load. Always a
|
|
// non-nil slice. The service fills each row's DerivedCount; this is the raw read.
|
|
func (d *DB) ListActivePlantingsForGarden(ctx context.Context, gardenID int64) ([]domain.Planting, error) {
|
|
return queryPlantings(ctx, d.sql,
|
|
`SELECT `+qualifyColumns("pl", plantingColumns)+` FROM plantings pl
|
|
JOIN garden_objects o ON o.id = pl.object_id
|
|
WHERE o.garden_id = ? AND pl.removed_at IS NULL
|
|
ORDER BY pl.id`,
|
|
gardenID)
|
|
}
|
|
|
|
// queryPlantings runs a planting query and scans every row. Like queryObjects it
|
|
// drains and closes the cursor before returning, so a transaction caller may
|
|
// write afterwards. Always a non-nil slice.
|
|
func queryPlantings(ctx context.Context, q queryer, query string, args ...any) ([]domain.Planting, error) {
|
|
rows, err := q.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: list plantings: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
plantings := []domain.Planting{}
|
|
for rows.Next() {
|
|
p, err := scanPlanting(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: scan planting: %w", err)
|
|
}
|
|
plantings = append(plantings, *p)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("store: iterate plantings: %w", err)
|
|
}
|
|
return plantings, nil
|
|
}
|
|
|
|
// ListActivePlantingsForObject returns an object's currently-planted plops
|
|
// (removed_at IS NULL). Always a non-nil slice. Used by FillRegion to avoid
|
|
// stacking new plops inside existing ones.
|
|
func (d *DB) ListActivePlantingsForObject(ctx context.Context, objectID int64) ([]domain.Planting, error) {
|
|
return queryPlantings(ctx, d.sql,
|
|
`SELECT `+plantingColumns+` FROM plantings WHERE object_id = ? AND removed_at IS NULL ORDER BY id`,
|
|
objectID)
|
|
}
|
|
|
|
// ListPlantingsForObject returns every plop in an object, removed ones included.
|
|
// Used when an object is deleted: the FK cascades its plantings away without the
|
|
// service seeing them, so they are snapshotted first or the delete would not be
|
|
// revertible. Always a non-nil slice.
|
|
func (d *DB) ListPlantingsForObject(ctx context.Context, objectID int64) ([]domain.Planting, error) {
|
|
return queryPlantings(ctx, d.sql,
|
|
`SELECT `+plantingColumns+` FROM plantings WHERE object_id = ? ORDER BY id`,
|
|
objectID)
|
|
}
|
|
|
|
// RestorePlanting re-inserts a deleted plop under its ORIGINAL id, preserving
|
|
// version and timestamps — the plop counterpart of RestoreObject, and subject to
|
|
// the same reasoning. Its parent object must exist again first, or the FK
|
|
// rejects it; the revert orders object restores ahead of planting restores.
|
|
func (d *DB) RestorePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) {
|
|
restored, err := scanPlanting(d.sql.QueryRowContext(ctx,
|
|
`INSERT INTO plantings
|
|
(id, object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at, removed_at,
|
|
seed_lot_id, version, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
RETURNING `+plantingColumns,
|
|
p.ID, p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label,
|
|
p.PlantedAt, p.RemovedAt, p.SeedLotID, p.Version, p.CreatedAt,
|
|
))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: restore planting: %w", err)
|
|
}
|
|
return restored, nil
|
|
}
|
|
|
|
// ClearObjectPlantings soft-removes the given plops of an object in one UPDATE
|
|
// (sets removed_at=date, bumps version) and returns how many rows it affected.
|
|
//
|
|
// It takes explicit ids rather than clearing "every active plop" so the caller
|
|
// can snapshot exactly the rows it is about to change. Clearing by predicate
|
|
// would let a plop created between the caller's read and this UPDATE be removed
|
|
// without a revision — cleared, but with no way to undo it.
|
|
func (d *DB) ClearObjectPlantings(ctx context.Context, objectID int64, date string, ids []int64) (int, error) {
|
|
if len(ids) == 0 {
|
|
return 0, nil
|
|
}
|
|
args := make([]any, 0, len(ids)+2)
|
|
args = append(args, date, objectID)
|
|
for _, id := range ids {
|
|
args = append(args, id)
|
|
}
|
|
res, err := d.sql.ExecContext(ctx,
|
|
`UPDATE plantings
|
|
SET removed_at = ?, version = version + 1,
|
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
|
WHERE object_id = ? AND removed_at IS NULL AND id IN (`+placeholders(len(ids))+`)`,
|
|
args...,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("store: clear object plantings: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("store: clear plantings rows: %w", err)
|
|
}
|
|
return int(n), nil
|
|
}
|
|
|
|
// GetPlanting returns the planting with the given id, or domain.ErrNotFound.
|
|
func (d *DB) GetPlanting(ctx context.Context, id int64) (*domain.Planting, error) {
|
|
p, err := scanPlanting(d.sql.QueryRowContext(ctx,
|
|
`SELECT `+plantingColumns+` FROM plantings WHERE id = ?`, id))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: get planting: %w", err)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// plantingInsert inserts one plantings row and returns it. Shared by
|
|
// CreatePlanting, the CreatePlantings batch and CopyGarden's in-transaction copy
|
|
// — with plantingInsertArgs supplying its parameters — so all three stay in step
|
|
// with the table. removed_at is never set here: a new plop is active, and "clear
|
|
// bed" sets removed_at later via UpdatePlanting.
|
|
const plantingInsert = `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at, seed_lot_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
RETURNING ` + plantingColumns
|
|
|
|
// plantingInsertArgs builds plantingInsert's parameters, parenting p to objectID
|
|
// (p's own object normally, and the copied object when copying a garden).
|
|
func plantingInsertArgs(objectID int64, p *domain.Planting) []any {
|
|
return []any{objectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.SeedLotID}
|
|
}
|
|
|
|
// CreatePlanting inserts a plop (fields already validated by the service) and
|
|
// returns the stored row.
|
|
func (d *DB) CreatePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) {
|
|
created, err := scanPlanting(d.sql.QueryRowContext(ctx, plantingInsert, plantingInsertArgs(p.ObjectID, p)...))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: insert planting: %w", err)
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
// CreatePlantings inserts many plops in a single transaction (one commit), for
|
|
// bulk fills. Returns the stored rows in order. An empty input is a no-op.
|
|
func (d *DB) CreatePlantings(ctx context.Context, plantings []*domain.Planting) ([]domain.Planting, error) {
|
|
if len(plantings) == 0 {
|
|
return []domain.Planting{}, nil
|
|
}
|
|
tx, err := d.sql.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: begin plantings tx: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck // no-op after a successful commit
|
|
|
|
out := make([]domain.Planting, 0, len(plantings))
|
|
for _, p := range plantings {
|
|
created, err := scanPlanting(tx.QueryRowContext(ctx, plantingInsert, plantingInsertArgs(p.ObjectID, p)...))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: insert planting (batch): %w", err)
|
|
}
|
|
out = append(out, *created)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("store: commit plantings: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// UpdatePlanting 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 the
|
|
// other mutable resources.
|
|
func (d *DB) UpdatePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) {
|
|
updated, err := scanPlanting(d.sql.QueryRowContext(ctx,
|
|
`UPDATE plantings
|
|
SET plant_id = ?, x_cm = ?, y_cm = ?, radius_cm = ?, count = ?, label = ?,
|
|
planted_at = ?, removed_at = ?, seed_lot_id = ?,
|
|
version = version + 1,
|
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
|
WHERE id = ? AND version = ?
|
|
RETURNING `+plantingColumns,
|
|
p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.RemovedAt, p.SeedLotID,
|
|
p.ID, p.Version,
|
|
))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
current, gerr := d.GetPlanting(ctx, p.ID)
|
|
if gerr != nil {
|
|
return nil, gerr
|
|
}
|
|
return current, domain.ErrVersionConflict
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: update planting: %w", err)
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// DeletePlanting hard-deletes a plop (for mistakes; "removed/harvested" flows set
|
|
// removed_at instead). Returns domain.ErrNotFound if no row was deleted.
|
|
func (d *DB) DeletePlanting(ctx context.Context, id int64) error {
|
|
res, err := d.sql.ExecContext(ctx, `DELETE FROM plantings WHERE id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("store: delete planting: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("store: planting delete rows: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return domain.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|