Add garden objects backend: polymorphic CRUD + /full (#10)
Garden objects (beds, bags, containers, in-ground, trees, paths, structures) in one polymorphic table, following the #7 service conventions. - store/objects.go: scanObject + Create (RETURNING), Get, ListForGarden (z_index order), version-guarded Update (RETURNING, returns current row on conflict), Delete (plantings cascade via FK). - store/plantings.go + plants.go: the read side /full needs now — ListActivePlantingsForGarden (removed_at IS NULL) and ListReferencedPlants (distinct plants used by active plantings). Both return empty until #14 adds plantings; #12/#14 extend these files. - service/objects.go: Create/Update(partial patch)/Delete/GardenFull, all (ctx, actorID, ...) through requireGardenRole(editor for mutations, viewer for /full). finalizeObject validates kind + shape (rect/circle; polygon reserved), finite dims in [1cm,100m], NaN/Inf rejection, loose bbox-overlaps-garden placement (partial overhang OK), hex color, valid JSON props, name/notes caps; normalizes rotation to [0,360). Plantable defaults by kind, overridable. - api/objects.go: POST /gardens/:id/objects, PATCH/DELETE /objects/:id, GET /gardens/:id/full ({garden,objects,plantings,plants}). props is any JSON value stored as text; PATCH is partial + required version, 409 returns the current row. Tests: service (defaults, plantable-by-kind, rotation normalize, validation rejects incl. off-field/polygon/bad-color/bad-props, partial patch + version conflict, cross-user ErrNotFound, delete, /full shape) and api (CRUD + /full, 409 envelope, cross-user 404, polygon/no-kind 400, props round-trip). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// objectColumns lists garden_objects columns in the order scanObject expects.
|
||||
const objectColumns = `id, garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
|
||||
rotation_deg, z_index, plantable, color, props, notes, version, created_at, updated_at`
|
||||
|
||||
func scanObject(s scanner) (*domain.GardenObject, error) {
|
||||
var (
|
||||
o domain.GardenObject
|
||||
plantable int64
|
||||
)
|
||||
if err := s.Scan(
|
||||
&o.ID, &o.GardenID, &o.Kind, &o.Name, &o.Shape, &o.Points,
|
||||
&o.XCM, &o.YCM, &o.WidthCM, &o.HeightCM, &o.RotationDeg, &o.ZIndex,
|
||||
&plantable, &o.Color, &o.Props, &o.Notes, &o.Version, &o.CreatedAt, &o.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.Plantable = plantable != 0
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
// CreateObject inserts a garden object (fields already validated by the service)
|
||||
// and returns the stored row.
|
||||
func (d *DB) CreateObject(ctx context.Context, o *domain.GardenObject) (*domain.GardenObject, error) {
|
||||
created, err := scanObject(d.sql.QueryRowContext(ctx,
|
||||
`INSERT INTO garden_objects
|
||||
(garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
|
||||
rotation_deg, z_index, plantable, color, props, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING `+objectColumns,
|
||||
o.GardenID, o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
|
||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props, o.Notes,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: insert object: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// GetObject returns the object with the given id, or domain.ErrNotFound.
|
||||
func (d *DB) GetObject(ctx context.Context, id int64) (*domain.GardenObject, error) {
|
||||
o, err := scanObject(d.sql.QueryRowContext(ctx,
|
||||
`SELECT `+objectColumns+` FROM garden_objects WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: get object: %w", err)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// ListObjectsForGarden returns a garden's objects, bottom-to-top (z_index then
|
||||
// id). Always a non-nil slice.
|
||||
func (d *DB) ListObjectsForGarden(ctx context.Context, gardenID int64) ([]domain.GardenObject, error) {
|
||||
rows, err := d.sql.QueryContext(ctx,
|
||||
`SELECT `+objectColumns+` FROM garden_objects WHERE garden_id = ? ORDER BY z_index, id`,
|
||||
gardenID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list objects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
objects := []domain.GardenObject{}
|
||||
for rows.Next() {
|
||||
o, err := scanObject(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan object: %w", err)
|
||||
}
|
||||
objects = append(objects, *o)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: iterate objects: %w", err)
|
||||
}
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
// UpdateObject applies a version-guarded update of all mutable columns (the
|
||||
// service merges partial patches onto the current row first). Returns the
|
||||
// updated row, or (current row, ErrVersionConflict) / ErrNotFound — the same
|
||||
// contract as UpdateGarden.
|
||||
func (d *DB) UpdateObject(ctx context.Context, o *domain.GardenObject) (*domain.GardenObject, error) {
|
||||
updated, err := scanObject(d.sql.QueryRowContext(ctx,
|
||||
`UPDATE garden_objects
|
||||
SET kind = ?, name = ?, shape = ?, points = ?, x_cm = ?, y_cm = ?,
|
||||
width_cm = ?, height_cm = ?, rotation_deg = ?, z_index = ?,
|
||||
plantable = ?, color = ?, props = ?, notes = ?,
|
||||
version = version + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ? AND version = ?
|
||||
RETURNING `+objectColumns,
|
||||
o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
|
||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props, o.Notes,
|
||||
o.ID, o.Version,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
current, gerr := d.GetObject(ctx, o.ID)
|
||||
if gerr != nil {
|
||||
return nil, gerr
|
||||
}
|
||||
return current, domain.ErrVersionConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: update object: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// DeleteObject removes an object; its plantings cascade via the FK. Returns
|
||||
// domain.ErrNotFound if no row was deleted.
|
||||
func (d *DB) DeleteObject(ctx context.Context, id int64) error {
|
||||
res, err := d.sql.ExecContext(ctx, `DELETE FROM garden_objects WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete object: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: object delete rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user