Build image / build-and-push (push) Successful in 8s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
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
|
|
}
|