Copy a garden (deep duplicate) from the gardens list (#46)
Build image / build-and-push (push) Successful in 16s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #46.
This commit is contained in:
2026-07-21 03:58:39 +00:00
committed by steve
parent e74fb308c1
commit e22bdd6cab
15 changed files with 752 additions and 52 deletions
+82
View File
@@ -163,6 +163,88 @@ func (d *DB) DeleteGarden(ctx context.Context, id int64) error {
return nil
}
// CopyGarden deep-copies garden srcID into a new garden owned by ownerID and
// named name, returning the new row. Objects are copied with their geometry,
// grid settings and z-order, and each object's ACTIVE plantings (removed_at IS
// NULL) are re-parented onto the copied object.
//
// Deliberately not copied:
// - public_token — the share link is a capability granted to the original, so a
// copy must not silently inherit a live public URL;
// - garden_shares — a copy is private to whoever made it;
// - removed plantings — the copy is a fresh layout, not the original's history.
//
// The whole copy runs in one transaction, so a failure part-way leaves no
// half-populated garden behind. Returns domain.ErrNotFound if srcID is gone.
func (d *DB) CopyGarden(ctx context.Context, srcID, ownerID int64, name string) (*domain.Garden, error) {
tx, err := d.sql.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("store: begin copy garden tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op after a successful commit
// INSERT ... SELECT copies the source's own columns; owner and name are
// supplied. If the source is gone the SELECT is empty, nothing is inserted,
// and RETURNING yields no rows.
created, err := scanGarden(tx.QueryRowContext(ctx,
`INSERT INTO gardens (owner_id, name, width_cm, height_cm, unit_pref, notes, grid_size_cm, snap_to_grid)
SELECT ?, ?, width_cm, height_cm, unit_pref, notes, grid_size_cm, snap_to_grid
FROM gardens WHERE id = ?
RETURNING `+gardenColumns,
ownerID, name, srcID,
))
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("store: copy garden: %w", err)
}
// Read the source's objects fully before inserting: SQLite can't have an open
// cursor and a write on the same connection, and the pool is pinned to one
// connection for in-memory DBs.
srcObjects, err := queryObjects(ctx, tx,
`SELECT `+objectColumns+` FROM garden_objects WHERE garden_id = ? ORDER BY id`, srcID)
if err != nil {
return nil, err
}
// objectIDs maps a source object id to its copy, so plantings can be
// re-parented onto the right new object.
objectIDs := make(map[int64]int64, len(srcObjects))
for _, o := range srcObjects {
copied, err := scanObject(tx.QueryRowContext(ctx, objectInsert, objectInsertArgs(created.ID, &o)...))
if err != nil {
return nil, fmt.Errorf("store: copy object: %w", err)
}
objectIDs[o.ID] = copied.ID
}
srcPlantings, err := queryPlantings(ctx, tx,
`SELECT `+qualifyColumns("pl", plantingColumns)+` FROM plantings pl
JOIN garden_objects o ON o.id = pl.object_id
WHERE o.garden_id = ? AND pl.removed_at IS NULL
ORDER BY pl.id`, srcID)
if err != nil {
return nil, err
}
for _, p := range srcPlantings {
objectID, ok := objectIDs[p.ObjectID]
if !ok {
// Unreachable: every active planting joins an object we just copied.
return nil, fmt.Errorf("store: copy planting %d: source object %d not copied", p.ID, p.ObjectID)
}
if _, err := scanPlanting(tx.QueryRowContext(ctx, plantingInsert, plantingInsertArgs(objectID, &p)...)); err != nil {
return nil, fmt.Errorf("store: copy planting: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("store: commit garden copy: %w", err)
}
return created, nil
}
// GetGardenByPublicToken returns the garden whose public_token matches, or
// domain.ErrNotFound. Possession of the token is the capability, so there is no
// owner/actor check here — the service exposes this only for the unauthenticated
+31 -13
View File
@@ -31,19 +31,30 @@ func scanObject(s scanner) (*domain.GardenObject, error) {
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,
`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,
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,
))
created, err := scanObject(d.sql.QueryRowContext(ctx, objectInsert, objectInsertArgs(o.GardenID, o)...))
if err != nil {
return nil, fmt.Errorf("store: insert object: %w", err)
}
@@ -66,10 +77,17 @@ func (d *DB) GetObject(ctx context.Context, id int64) (*domain.GardenObject, err
// 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,
return queryObjects(ctx, d.sql,
`SELECT `+objectColumns+` FROM garden_objects WHERE garden_id = ? ORDER BY z_index, id`,
gardenID,
)
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)
}
+29 -36
View File
@@ -30,13 +30,19 @@ func scanPlanting(s scanner) (*domain.Planting, error) {
// IS NULL) across all objects in a garden — the editor's one-shot load. Always a
// non-nil slice. The service fills each row's DerivedCount; this is the raw read.
func (d *DB) ListActivePlantingsForGarden(ctx context.Context, gardenID int64) ([]domain.Planting, error) {
rows, err := d.sql.QueryContext(ctx,
return queryPlantings(ctx, d.sql,
`SELECT `+qualifyColumns("pl", plantingColumns)+` FROM plantings pl
JOIN garden_objects o ON o.id = pl.object_id
WHERE o.garden_id = ? AND pl.removed_at IS NULL
ORDER BY pl.id`,
gardenID,
)
gardenID)
}
// queryPlantings runs a planting query and scans every row. Like queryObjects it
// drains and closes the cursor before returning, so a transaction caller may
// write afterwards. Always a non-nil slice.
func queryPlantings(ctx context.Context, q queryer, query string, args ...any) ([]domain.Planting, error) {
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("store: list plantings: %w", err)
}
@@ -60,27 +66,9 @@ func (d *DB) ListActivePlantingsForGarden(ctx context.Context, gardenID int64) (
// (removed_at IS NULL). Always a non-nil slice. Used by FillRegion to avoid
// stacking new plops inside existing ones.
func (d *DB) ListActivePlantingsForObject(ctx context.Context, objectID int64) ([]domain.Planting, error) {
rows, err := d.sql.QueryContext(ctx,
return queryPlantings(ctx, d.sql,
`SELECT `+plantingColumns+` FROM plantings WHERE object_id = ? AND removed_at IS NULL ORDER BY id`,
objectID,
)
if err != nil {
return nil, fmt.Errorf("store: list object plantings: %w", err)
}
defer rows.Close()
plantings := []domain.Planting{}
for rows.Next() {
p, err := scanPlanting(rows)
if err != nil {
return nil, fmt.Errorf("store: scan planting: %w", err)
}
plantings = append(plantings, *p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate plantings: %w", err)
}
return plantings, nil
objectID)
}
// ClearObjectPlantings soft-removes every active plop in an object in one UPDATE
@@ -116,16 +104,25 @@ func (d *DB) GetPlanting(ctx context.Context, id int64) (*domain.Planting, error
return p, nil
}
// plantingInsert inserts one plantings row and returns it. Shared by
// CreatePlanting, the CreatePlantings batch and CopyGarden's in-transaction copy
// — with plantingInsertArgs supplying its parameters — so all three stay in step
// with the table. removed_at is never set here: a new plop is active, and "clear
// bed" sets removed_at later via UpdatePlanting.
const plantingInsert = `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING ` + plantingColumns
// plantingInsertArgs builds plantingInsert's parameters, parenting p to objectID
// (p's own object normally, and the copied object when copying a garden).
func plantingInsertArgs(objectID int64, p *domain.Planting) []any {
return []any{objectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt}
}
// CreatePlanting inserts a plop (fields already validated by the service) and
// returns the stored row. removed_at is always NULL on create — a new plop is
// active; "clear bed" sets removed_at later via UpdatePlanting.
// returns the stored row.
func (d *DB) CreatePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) {
created, err := scanPlanting(d.sql.QueryRowContext(ctx,
`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING `+plantingColumns,
p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt,
))
created, err := scanPlanting(d.sql.QueryRowContext(ctx, plantingInsert, plantingInsertArgs(p.ObjectID, p)...))
if err != nil {
return nil, fmt.Errorf("store: insert planting: %w", err)
}
@@ -144,13 +141,9 @@ func (d *DB) CreatePlantings(ctx context.Context, plantings []*domain.Planting)
}
defer tx.Rollback() //nolint:errcheck // no-op after a successful commit
const stmt = `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING ` + plantingColumns
out := make([]domain.Planting, 0, len(plantings))
for _, p := range plantings {
created, err := scanPlanting(tx.QueryRowContext(ctx, stmt,
p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt))
created, err := scanPlanting(tx.QueryRowContext(ctx, plantingInsert, plantingInsertArgs(p.ObjectID, p)...))
if err != nil {
return nil, fmt.Errorf("store: insert planting (batch): %w", err)
}
+8
View File
@@ -5,6 +5,7 @@
package store
import (
"context"
"database/sql"
"fmt"
"net/url"
@@ -99,6 +100,13 @@ func pragmaName(p string) string {
return strings.ToLower(strings.TrimSpace(p))
}
// queryer is the read surface shared by *sql.DB and *sql.Tx, so a list helper can
// serve both a standalone read and one inside a transaction. (Its single-row
// counterpart is `scanner`, in users.go.)
type queryer interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}
// qualifyColumns prefixes each comma-separated column in cols with "alias." so a
// shared column list can be used in a JOIN — e.g. qualifyColumns("pl", "id, x")
// → "pl.id, pl.x". Whitespace/newlines in the list are trimmed.