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 }