package postgres import ( "database/sql" "gardomatic.kleiax.de/internal/storage" ) // SpeciesModel stores global and garden-specific species data in PostgreSQL. // SpeciesModel implements storage.SpeciesModelInterface for global and // garden-owned species records. 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_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, s.notes, s.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), s.image_id, s.created_at, s.updated_at, s.version, COALESCE(s.created_by, 0), COALESCE(s.updated_by, 0)` func scanSpecies(s scanner) (storage.Species, error) { var species storage.Species err := s.Scan( &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.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.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, species.HarvestDayTo, species.Notes, jsonValue(species.Attributes), species.ImageID, nullableUserID(species.CreatedBy), nullableUserID(species.UpdatedBy), } } // Insert creates a global or garden-owned species record. func (m SpeciesModel) Insert(species storage.Species) (storage.Species, error) { ctx, cancel := contextWithTimeout() defer cancel() 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_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, '', $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, ) if err != nil { return storage.Species{}, recordError(err) } species.HeightCM = species.HeightCMTo return species, nil } // Get returns a global or garden-owned species visible in gardenID. func (m SpeciesModel) Get(gardenID, id int) (storage.Species, error) { ctx, cancel := contextWithTimeout() defer cancel() species, err := scanSpecies(m.DB.QueryRowContext(ctx, `SELECT `+speciesColumns+` FROM species s LEFT JOIN species_categories c ON c.id = s.category_id LEFT JOIN images i ON i.id=s.image_id WHERE s.id = $1 AND (s.garden_id IS NULL OR s.garden_id = $2)`, id, gardenID)) if err != nil { return storage.Species{}, recordError(err) } species.HeightCM = species.HeightCMTo return species, nil } // GetAllForGarden lists global species and species owned by a garden. func (m SpeciesModel) GetAllForGarden(gardenID int) ([]storage.Species, error) { ctx, cancel := contextWithTimeout() defer cancel() rows, err := m.DB.QueryContext(ctx, `SELECT `+speciesColumns+` FROM species s LEFT JOIN species_categories c ON c.id = s.category_id LEFT JOIN images i ON i.id=s.image_id WHERE s.garden_id IS NULL OR s.garden_id = $1 ORDER BY s.common_name, s.cultivar, s.id`, gardenID) if err != nil { return nil, err } defer rows.Close() allSpecies := []storage.Species{} for rows.Next() { species, err := scanSpecies(rows) if err != nil { return nil, err } allSpecies = append(allSpecies, species) } return allSpecies, rows.Err() } // Update changes a visible species using optimistic locking. func (m SpeciesModel) Update(gardenID int, species storage.Species) (storage.Species, error) { ctx, cancel := contextWithTimeout() defer cancel() args := speciesArgs(species) // 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[: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_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 { if err == sql.ErrNoRows { return storage.Species{}, storage.ErrEditConflict } return storage.Species{}, recordError(err) } return species, nil } // Delete removes a species owned by the supplied garden. func (m SpeciesModel) Delete(gardenID, id int) error { return deleteByGardenID(m.DB, `DELETE FROM species WHERE garden_id IS NOT DISTINCT FROM NULLIF($1, 0) AND id = $2`, gardenID, id) }