Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
185 lines
7.1 KiB
Go
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
|
|
}
|