Initial commit
CI / test (push) Canceled after 0s

This commit is contained in:
2026-09-12 22:22:17 +02:00
commit 904d14b64c
314 changed files with 31884 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
package postgres
import (
"database/sql"
"gardomatic.kleiax.de/internal/storage"
)
// LocationModel stores garden locations in PostgreSQL.
// LocationModel implements storage.LocationModelInterface for PostgreSQL and
// scopes hierarchical locations to their garden.
type LocationModel struct{ DB *sql.DB }
const locationColumns = `l.id, l.garden_id, l.parent_id, l.name, l.description, l.kind, l.area_sqm,
l.sun_exposure, l.soil_condition, l.soil_reaction, l.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), l.image_id, l.created_at, l.updated_at, l.version,
COALESCE(l.created_by, 0), COALESCE(l.updated_by, 0)`
func scanLocation(s scanner) (storage.Location, error) {
var location storage.Location
err := s.Scan(
&location.ID, &location.GardenID, &location.ParentID, &location.Name,
&location.Description, &location.Kind, &location.AreaSQM, &location.SunExposure, &location.SoilCondition, &location.SoilReaction,
&location.Attributes, &location.ImageData, &location.ImageID, &location.CreatedAt, &location.UpdatedAt, &location.Version, &location.CreatedBy, &location.UpdatedBy,
)
return location, err
}
// Insert creates a location in its garden.
func (m LocationModel) Insert(location storage.Location) (storage.Location, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
err := m.DB.QueryRowContext(ctx, `
INSERT INTO locations
(garden_id, parent_id, name, description, kind, area_sqm, sun_exposure, soil_condition, soil_reaction, attributes, image_data, image_id, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, '', $11, $12, $13)
RETURNING id, created_at, updated_at, version`,
location.GardenID, location.ParentID, location.Name, location.Description,
location.Kind, location.AreaSQM, location.SunExposure, location.SoilCondition, location.SoilReaction, jsonValue(location.Attributes), location.ImageID, nullableUserID(location.CreatedBy), nullableUserID(location.UpdatedBy),
).Scan(&location.ID, &location.CreatedAt, &location.UpdatedAt, &location.Version)
if err != nil {
return storage.Location{}, err
}
return location, nil
}
// Get returns a location within its garden.
func (m LocationModel) Get(gardenID, id int) (storage.Location, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
location, err := scanLocation(m.DB.QueryRowContext(ctx, `SELECT `+locationColumns+` FROM locations l LEFT JOIN images i ON i.id=l.image_id WHERE l.garden_id = $1 AND l.id = $2`, gardenID, id))
if err != nil {
return storage.Location{}, recordError(err)
}
return location, nil
}
// GetAllForGarden lists locations in a garden.
func (m LocationModel) GetAllForGarden(gardenID int) ([]storage.Location, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
rows, err := m.DB.QueryContext(ctx, `SELECT `+locationColumns+`
FROM locations l LEFT JOIN images i ON i.id=l.image_id WHERE l.garden_id = $1 ORDER BY l.name, l.id`, gardenID)
if err != nil {
return nil, err
}
defer rows.Close()
locations := []storage.Location{}
for rows.Next() {
location, err := scanLocation(rows)
if err != nil {
return nil, err
}
locations = append(locations, location)
}
return locations, rows.Err()
}
// Update changes a location using optimistic locking.
func (m LocationModel) Update(gardenID int, location storage.Location) (storage.Location, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
err := m.DB.QueryRowContext(ctx, `
UPDATE locations SET parent_id = $1, name = $2, description = $3, kind = $4,
area_sqm = $5, sun_exposure = $6, soil_condition = $7, soil_reaction = $8, attributes = $9, image_data = '', image_id = $10, updated_by = $11,
updated_at = CURRENT_TIMESTAMP, version = version + 1
WHERE garden_id = $12 AND id = $13 AND version = $14
RETURNING updated_at, version`,
location.ParentID, location.Name, location.Description, location.Kind,
location.AreaSQM, location.SunExposure, location.SoilCondition, location.SoilReaction, jsonValue(location.Attributes), location.ImageID, nullableUserID(location.UpdatedBy),
gardenID, location.ID, location.Version,
).Scan(&location.UpdatedAt, &location.Version)
if err != nil {
if err == sql.ErrNoRows {
return storage.Location{}, storage.ErrEditConflict
}
return storage.Location{}, err
}
return location, nil
}
// Delete removes a location within its garden.
func (m LocationModel) Delete(gardenID, id int) error {
ctx, cancel := contextWithTimeout()
defer cancel()
result, err := m.DB.ExecContext(ctx, `DELETE FROM locations WHERE garden_id = $1 AND id = $2`, gardenID, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return storage.ErrRecordNotFound
}
return nil
}