Files
kleiax 904d14b64c
CI / test (push) Canceled after 0s
Initial commit
2026-09-12 22:22:17 +02:00

140 lines
5.3 KiB
Go

package postgres
import (
"database/sql"
"gardomatic.kleiax.de/internal/storage"
)
// PlantLocationModel stores plant-to-location assignments in PostgreSQL.
// PlantLocationModel implements storage.PlantLocationModelInterface for
// PostgreSQL and verifies both plant and location membership in the garden.
type PlantLocationModel struct{ DB *sql.DB }
const plantLocationQualifiedColumns = `pl.id, pl.plant_id, pl.location_id, pl.quantity, pl.planted_at,
pl.removed_at, pl.notes, pl.created_at, pl.version`
func scanPlantLocation(s scanner) (storage.PlantLocation, error) {
var plantLocation storage.PlantLocation
err := s.Scan(
&plantLocation.ID, &plantLocation.PlantID, &plantLocation.LocationID,
&plantLocation.Quantity, &plantLocation.PlantedAt, &plantLocation.RemovedAt,
&plantLocation.Notes, &plantLocation.CreatedAt, &plantLocation.Version,
)
return plantLocation, err
}
// Insert assigns a plant to a location in the same garden.
func (m PlantLocationModel) Insert(gardenID int, plantLocation storage.PlantLocation) (storage.PlantLocation, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
err := m.DB.QueryRowContext(ctx, `
INSERT INTO plant_locations
(plant_id, location_id, quantity, planted_at, removed_at, notes)
SELECT $2, $3, $4, $5, $6, $7
FROM plants p, locations l
WHERE p.id = $2 AND p.garden_id = $1 AND l.id = $3 AND l.garden_id = $1
RETURNING id, created_at, version`,
gardenID, plantLocation.PlantID, plantLocation.LocationID, plantLocation.Quantity,
plantLocation.PlantedAt, plantLocation.RemovedAt, plantLocation.Notes,
).Scan(&plantLocation.ID, &plantLocation.CreatedAt, &plantLocation.Version)
if err != nil {
return storage.PlantLocation{}, recordError(err)
}
return plantLocation, nil
}
// Get returns a plant assignment within its garden.
func (m PlantLocationModel) Get(gardenID, id int) (storage.PlantLocation, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
plantLocation, err := scanPlantLocation(m.DB.QueryRowContext(ctx,
`SELECT `+plantLocationQualifiedColumns+` FROM plant_locations pl
JOIN plants p ON p.id = pl.plant_id WHERE p.garden_id = $1 AND pl.id = $2`, gardenID, id))
if err != nil {
return storage.PlantLocation{}, recordError(err)
}
return plantLocation, nil
}
func (m PlantLocationModel) getAll(query string, gardenID, id int) ([]storage.PlantLocation, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
rows, err := m.DB.QueryContext(ctx, query, gardenID, id)
if err != nil {
return nil, err
}
defer rows.Close()
plantLocations := []storage.PlantLocation{}
for rows.Next() {
plantLocation, err := scanPlantLocation(rows)
if err != nil {
return nil, err
}
plantLocations = append(plantLocations, plantLocation)
}
return plantLocations, rows.Err()
}
// GetAllForPlant lists a plant's current and historical placements.
func (m PlantLocationModel) GetAllForPlant(gardenID, plantID int) ([]storage.PlantLocation, error) {
return m.getAll(`SELECT `+plantLocationQualifiedColumns+`
FROM plant_locations pl JOIN plants p ON p.id = pl.plant_id
WHERE p.garden_id = $1 AND pl.plant_id = $2 ORDER BY pl.created_at, pl.id`, gardenID, plantID)
}
// GetAllForLocation lists current and historical plant placements at a location.
func (m PlantLocationModel) GetAllForLocation(gardenID, locationID int) ([]storage.PlantLocation, error) {
return m.getAll(`SELECT `+plantLocationQualifiedColumns+`
FROM plant_locations pl JOIN locations l ON l.id = pl.location_id
WHERE l.garden_id = $1 AND pl.location_id = $2 ORDER BY pl.created_at, pl.id`, gardenID, locationID)
}
// Update changes a plant assignment using optimistic locking.
func (m PlantLocationModel) Update(gardenID int, plantLocation storage.PlantLocation) (storage.PlantLocation, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
result, err := m.DB.ExecContext(ctx, `
UPDATE plant_locations SET plant_id = $1, location_id = $2, quantity = $3,
planted_at = $4, removed_at = $5, notes = $6, version = version + 1
WHERE id = $7 AND version = $8
AND EXISTS (SELECT 1 FROM plants p WHERE p.id = plant_locations.plant_id AND p.garden_id = $9)
AND EXISTS (SELECT 1 FROM plants p WHERE p.id = $1 AND p.garden_id = $9)
AND EXISTS (SELECT 1 FROM locations l WHERE l.id = $2 AND l.garden_id = $9)`,
plantLocation.PlantID, plantLocation.LocationID, plantLocation.Quantity,
plantLocation.PlantedAt, plantLocation.RemovedAt, plantLocation.Notes,
plantLocation.ID, plantLocation.Version, gardenID)
if err != nil {
return storage.PlantLocation{}, err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return storage.PlantLocation{}, err
}
if rowsAffected == 0 {
return storage.PlantLocation{}, storage.ErrEditConflict
}
plantLocation.Version++
return plantLocation, nil
}
// Delete removes a plant assignment within its garden.
func (m PlantLocationModel) Delete(gardenID, id int) error {
ctx, cancel := contextWithTimeout()
defer cancel()
result, err := m.DB.ExecContext(ctx, `DELETE FROM plant_locations pl USING plants p
WHERE pl.id = $2 AND p.id = pl.plant_id AND p.garden_id = $1`, gardenID, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return storage.ErrRecordNotFound
}
return nil
}