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, 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.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, 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.Version, p.CreatedAt, )) if err != nil { return nil, fmt.Errorf("store: restore planting: %w", err) } return restored, nil } // ClearObjectPlantings soft-removes every active plop in an object in one UPDATE // (sets removed_at=date, bumps version) and returns how many rows it affected. func (d *DB) ClearObjectPlantings(ctx context.Context, objectID int64, date string) (int, error) { 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`, date, objectID, ) 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) 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} } // 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 = ?, 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.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 }