140 lines
5.3 KiB
Go
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
|
|
}
|