Files
steve f208da94d6
Build image / build-and-push (push) Successful in 5s
Revision history: change sets + revisions + revert — the undo substrate (#48) (#61)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:07:30 +00:00

185 lines
7.1 KiB
Go

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, grid_size_cm, snap_to_grid, notes, version, created_at, updated_at`
func scanObject(s scanner) (*domain.GardenObject, error) {
var (
o domain.GardenObject
plantable int64
snap 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.GridSizeCM, &snap, &o.Notes, &o.Version, &o.CreatedAt, &o.UpdatedAt,
); err != nil {
return nil, err
}
o.Plantable = plantable != 0
o.SnapToGrid = snap != 0
return &o, nil
}
// objectInsert inserts one garden_objects row and returns it. It is shared by
// CreateObject and CopyGarden's in-transaction copy, with objectInsertArgs
// supplying its parameters, so a column added to the table can't be wired into
// one path and silently dropped from the other.
const objectInsert = `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, grid_size_cm, snap_to_grid, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING ` + objectColumns
// objectInsertArgs builds objectInsert's parameters, placing o in gardenID (which
// is o's own garden on create, and the new garden when copying).
func objectInsertArgs(gardenID int64, o *domain.GardenObject) []any {
return []any{
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.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes,
}
}
// 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, objectInsert, objectInsertArgs(o.GardenID, o)...))
if err != nil {
return nil, fmt.Errorf("store: insert object: %w", err)
}
return created, nil
}
// RestoreObject re-inserts a deleted object under its ORIGINAL id, preserving
// version and timestamps — the inverse of a delete, for reverting a change set
// (#48). SQLite allows an explicit INTEGER PRIMARY KEY insert once the row is
// gone; if the id has been taken again the insert fails on the primary key, which
// is the correct outcome (the service checks first and reports a conflict).
//
// Deliberately not routed through objectInsert: that omits id/version/timestamps
// because a normal create must let the database assign them.
func (d *DB) RestoreObject(ctx context.Context, o *domain.GardenObject) (*domain.GardenObject, error) {
restored, err := scanObject(d.sql.QueryRowContext(ctx,
`INSERT INTO garden_objects
(id, garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes,
version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
RETURNING `+objectColumns,
o.ID, 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.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes, o.Version, o.CreatedAt,
))
if err != nil {
return nil, fmt.Errorf("store: restore object: %w", err)
}
return restored, 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) {
return queryObjects(ctx, d.sql,
`SELECT `+objectColumns+` FROM garden_objects WHERE garden_id = ? ORDER BY z_index, id`,
gardenID)
}
// queryObjects runs an object query and scans every row. The result set is fully
// drained and the cursor closed before returning, so a caller inside a
// transaction may write on the same connection afterwards. Always a non-nil
// slice.
func queryObjects(ctx context.Context, q queryer, query string, args ...any) ([]domain.GardenObject, error) {
rows, err := q.QueryContext(ctx, query, args...)
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 = ?, grid_size_cm = ?, snap_to_grid = ?, 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.GridSizeCM, boolToInt(o.SnapToGrid), 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
}