Support species height and width ranges
CI / test (push) Failing after 3m6s

This commit is contained in:
2026-09-16 20:21:41 +02:00
parent 87809b2344
commit ef608551ad
14 changed files with 271 additions and 69 deletions
@@ -0,0 +1,6 @@
ALTER TABLE species
DROP COLUMN width_cm_to,
DROP COLUMN width_cm_from,
DROP COLUMN height_cm_from;
ALTER TABLE species RENAME COLUMN height_cm_to TO height_cm;
@@ -0,0 +1,6 @@
ALTER TABLE species RENAME COLUMN height_cm TO height_cm_to;
ALTER TABLE species
ADD COLUMN height_cm_from integer,
ADD COLUMN width_cm_from integer,
ADD COLUMN width_cm_to integer;
+24 -12
View File
@@ -13,7 +13,8 @@ type SpeciesModel struct{ DB *sql.DB }
const speciesColumns = `s.id, s.garden_id, s.common_name, s.cultivar, s.botanical_name,
s.category_id, COALESCE(c.name, ''), s.sun_exposure, s.soil_condition, s.soil_reaction,
s.winter_protection, s.spacing_cm, s.height_cm,
s.winter_protection, s.spacing_cm, s.height_cm_from, s.height_cm_to,
s.width_cm_from, s.width_cm_to,
s.sow_month_from, s.sow_day_from, s.sow_month_to, s.sow_day_to,
s.planting_month_from, s.planting_day_from, s.planting_month_to, s.planting_day_to,
s.harvest_month_from, s.harvest_day_from, s.harvest_month_to, s.harvest_day_to,
@@ -26,19 +27,26 @@ func scanSpecies(s scanner) (storage.Species, error) {
&species.ID, &species.GardenID, &species.CommonName, &species.Cultivar,
&species.BotanicalName, &species.CategoryID, &species.Category, &species.SunExposure, &species.SoilCondition,
&species.SoilReaction, &species.WinterProtection, &species.SpacingCM,
&species.HeightCM, &species.SowMonthFrom, &species.SowDayFrom, &species.SowMonthTo,
&species.HeightCMFrom, &species.HeightCMTo, &species.WidthCMFrom, &species.WidthCMTo,
&species.SowMonthFrom, &species.SowDayFrom, &species.SowMonthTo,
&species.SowDayTo, &species.PlantingMonthFrom, &species.PlantingDayFrom, &species.PlantingMonthTo, &species.PlantingDayTo, &species.HarvestMonthFrom, &species.HarvestDayFrom,
&species.HarvestMonthTo, &species.HarvestDayTo, &species.Notes, &species.Attributes, &species.ImageData, &species.ImageID,
&species.CreatedAt, &species.UpdatedAt, &species.Version, &species.CreatedBy, &species.UpdatedBy,
)
species.HeightCM = species.HeightCMTo
return species, err
}
func speciesArgs(species storage.Species) []any {
heightTo := species.HeightCMTo
if heightTo == nil {
heightTo = species.HeightCM
}
return []any{
species.GardenID, species.CommonName, species.Cultivar, species.BotanicalName,
species.CategoryID, species.SunExposure, species.SoilCondition, species.SoilReaction,
species.WinterProtection, species.SpacingCM, species.HeightCM,
species.WinterProtection, species.SpacingCM, species.HeightCMFrom, heightTo,
species.WidthCMFrom, species.WidthCMTo,
species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo,
species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo,
species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo,
@@ -53,13 +61,15 @@ func (m SpeciesModel) Insert(species storage.Species) (storage.Species, error) {
query := `
INSERT INTO species (
garden_id, common_name, cultivar, botanical_name, category_id, sun_exposure,
soil_condition, soil_reaction, winter_protection, spacing_cm, height_cm,
soil_condition, soil_reaction, winter_protection, spacing_cm,
height_cm_from, height_cm_to, width_cm_from, width_cm_to,
sow_month_from, sow_day_from, sow_month_to, sow_day_to,
planting_month_from, planting_day_from, planting_month_to, planting_day_to,
harvest_month_from, harvest_day_from, harvest_month_to, harvest_day_to,
notes, attributes, image_data, image_id, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
$13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, '', $26, $27, $28)
$13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24,
$25, $26, $27, $28, '', $29, $30, $31)
RETURNING id, created_at, updated_at, version`
err := m.DB.QueryRowContext(ctx, query, speciesArgs(species)...).Scan(
&species.ID, &species.CreatedAt, &species.UpdatedAt, &species.Version,
@@ -67,6 +77,7 @@ func (m SpeciesModel) Insert(species storage.Species) (storage.Species, error) {
if err != nil {
return storage.Species{}, recordError(err)
}
species.HeightCM = species.HeightCMTo
return species, nil
}
@@ -78,6 +89,7 @@ func (m SpeciesModel) Get(gardenID, id int) (storage.Species, error) {
if err != nil {
return storage.Species{}, recordError(err)
}
species.HeightCM = species.HeightCMTo
return species, nil
}
@@ -113,18 +125,18 @@ func (m SpeciesModel) Update(gardenID int, species storage.Species) (storage.Spe
// created_by is immutable and is intentionally not part of the UPDATE.
// Remove it from the shared insert argument list so PostgreSQL does not
// receive an unused, untyped parameter between image_id and updated_by.
args = append(args[:26], args[27])
args = append(args[:29], args[30])
args = append(args, gardenID, species.ID, species.Version)
err := m.DB.QueryRowContext(ctx, `
UPDATE species SET garden_id = $1, common_name = $2, cultivar = $3,
botanical_name = $4, category_id = $5, sun_exposure = $6, soil_condition = $7,
soil_reaction = $8, winter_protection = $9, spacing_cm = $10,
height_cm = $11, sow_month_from = $12, sow_day_from = $13,
sow_month_to = $14, sow_day_to = $15, planting_month_from=$16,
planting_day_from=$17, planting_month_to=$18, planting_day_to=$19,
harvest_month_from = $20, harvest_day_from = $21, harvest_month_to = $22, harvest_day_to = $23,
notes = $24, attributes = $25, image_data = '', image_id = $26, updated_by = $27, updated_at = CURRENT_TIMESTAMP, version = version + 1
WHERE garden_id IS NOT DISTINCT FROM NULLIF($28, 0) AND id = $29 AND version = $30
height_cm_from = $11, height_cm_to = $12, width_cm_from = $13, width_cm_to = $14,
sow_month_from = $15, sow_day_from = $16, sow_month_to = $17, sow_day_to = $18,
planting_month_from=$19, planting_day_from=$20, planting_month_to=$21, planting_day_to=$22,
harvest_month_from = $23, harvest_day_from = $24, harvest_month_to = $25, harvest_day_to = $26,
notes = $27, attributes = $28, image_data = '', image_id = $29, updated_by = $30, updated_at = CURRENT_TIMESTAMP, version = version + 1
WHERE garden_id IS NOT DISTINCT FROM NULLIF($31, 0) AND id = $32 AND version = $33
RETURNING updated_at, version`, args...,
).Scan(&species.UpdatedAt, &species.Version)
if err != nil {
+31 -13
View File
@@ -19,19 +19,24 @@ type SpeciesModelInterface interface {
// Species contains reusable botanical and cultivation master data.
type Species struct {
ID int `json:"id"`
GardenID *int `json:"garden_id,omitempty"`
CommonName string `json:"common_name"`
Cultivar string `json:"cultivar"`
BotanicalName string `json:"botanical_name"`
CategoryID *int `json:"category_id,omitempty"`
Category string `json:"category"`
SunExposure *string `json:"sun_exposure,omitempty"`
SoilCondition *string `json:"soil_condition,omitempty"`
SoilReaction *string `json:"soil_reaction,omitempty"`
WinterProtection *string `json:"winter_protection,omitempty"`
SpacingCM *int `json:"spacing_cm,omitempty"`
ID int `json:"id"`
GardenID *int `json:"garden_id,omitempty"`
CommonName string `json:"common_name"`
Cultivar string `json:"cultivar"`
BotanicalName string `json:"botanical_name"`
CategoryID *int `json:"category_id,omitempty"`
Category string `json:"category"`
SunExposure *string `json:"sun_exposure,omitempty"`
SoilCondition *string `json:"soil_condition,omitempty"`
SoilReaction *string `json:"soil_reaction,omitempty"`
WinterProtection *string `json:"winter_protection,omitempty"`
SpacingCM *int `json:"spacing_cm,omitempty"`
// HeightCM is retained as a deprecated API alias for HeightCMTo.
HeightCM *int `json:"height_cm,omitempty"`
HeightCMFrom *int `json:"height_cm_from,omitempty"`
HeightCMTo *int `json:"height_cm_to,omitempty"`
WidthCMFrom *int `json:"width_cm_from,omitempty"`
WidthCMTo *int `json:"width_cm_to,omitempty"`
SowMonthFrom *int `json:"sow_month_from,omitempty"`
SowDayFrom *int `json:"sow_day_from,omitempty"`
SowMonthTo *int `json:"sow_month_to,omitempty"`
@@ -72,7 +77,12 @@ func ValidateSpecies(v *validate.Validator, species Species) {
validateOptionalEnum(v, "soil_condition", species.SoilCondition, "dry", "moist", "boggy")
validateOptionalEnum(v, "soil_reaction", species.SoilReaction, "alkaline", "acidic", "neutral")
validateOptionalPositive(v, "spacing_cm", species.SpacingCM)
validateOptionalPositive(v, "height_cm", species.HeightCM)
heightTo := species.HeightCMTo
if heightTo == nil {
heightTo = species.HeightCM
}
validateOptionalRange(v, "height_cm", species.HeightCMFrom, heightTo)
validateOptionalRange(v, "width_cm", species.WidthCMFrom, species.WidthCMTo)
validateOptionalCalendarPart(v, "sow_month_from", species.SowMonthFrom, 12)
validateOptionalCalendarPart(v, "sow_day_from", species.SowDayFrom, 31)
validateOptionalCalendarPart(v, "sow_month_to", species.SowMonthTo, 12)
@@ -99,6 +109,14 @@ func validateOptionalPositive(v *validate.Validator, field string, value *int) {
}
}
func validateOptionalRange(v *validate.Validator, field string, from, to *int) {
validateOptionalPositive(v, field+"_from", from)
validateOptionalPositive(v, field+"_to", to)
if from != nil && to != nil {
v.Check(*from <= *to, field+"_from", "must not be greater than "+field+"_to")
}
}
func validateOptionalCalendarPart(v *validate.Validator, field string, value *int, maximum int) {
if value != nil {
v.Check(*value >= 1 && *value <= maximum, field, "is outside the valid range")
+39
View File
@@ -0,0 +1,39 @@
package storage
import (
"testing"
"gardomatic.kleiax.de/internal/platform/validate"
)
func TestValidateSpeciesDimensions(t *testing.T) {
tests := []struct {
name string
species Species
wantField string
}{
{name: "valid ranges", species: Species{CommonName: "Rose", HeightCMFrom: intPointer(40), HeightCMTo: intPointer(80), WidthCMFrom: intPointer(30), WidthCMTo: intPointer(60)}},
{name: "height starts above end", species: Species{CommonName: "Rose", HeightCMFrom: intPointer(81), HeightCMTo: intPointer(80)}, wantField: "height_cm_from"},
{name: "width starts above end", species: Species{CommonName: "Rose", WidthCMFrom: intPointer(61), WidthCMTo: intPointer(60)}, wantField: "width_cm_from"},
{name: "non-positive height", species: Species{CommonName: "Rose", HeightCMTo: intPointer(0)}, wantField: "height_cm_to"},
{name: "deprecated height alias", species: Species{CommonName: "Rose", HeightCM: intPointer(80)}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := validate.New()
ValidateSpecies(v, tt.species)
_, hasError := v.Errors[tt.wantField]
if tt.wantField == "" && !v.Valid() {
t.Fatalf("unexpected validation errors: %v", v.Errors)
}
if tt.wantField != "" && !hasError {
t.Fatalf("expected validation error for %s, got %v", tt.wantField, v.Errors)
}
})
}
}
func intPointer(value int) *int {
return &value
}